Ryan Malloy dc987bd0e4 Replace hardcoded slate-* classes with theme-aware sb-* utilities
WaveformViewer, SchematicViewer, and SimulationLog now use CSS
variable-backed utility classes (bg-sb-surface, text-sb-muted,
border-sb-border, etc.) instead of hardcoded Tailwind slate colors.
Light mode embed theme now works through the variable system rather
than brute-force class overrides in embed-theme.css.
2026-03-05 19:26:23 -07:00

351 lines
9.9 KiB
TypeScript

import { useRef, useEffect, useState, useCallback } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
// uPlot is a vanilla JS lib; import it and its CSS
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
interface WaveformViewerProps {
waveform: WaveformData;
className?: string;
}
function getWfColors() {
const style = getComputedStyle(document.documentElement);
return {
axis: style.getPropertyValue('--color-wf-axis').trim() || '#475569',
grid: style.getPropertyValue('--color-wf-grid').trim() || 'rgba(51, 65, 85, 0.5)',
tick: style.getPropertyValue('--color-wf-tick').trim() || '#334155',
};
}
function buildOpts(
waveform: WaveformData,
width: number,
height: number,
xType: string,
yLabel: string,
): uPlot.Options {
const traceNames = Object.keys(waveform.y_data);
const wfColors = getWfColors();
const series: uPlot.Series[] = [
{ label: xType === 'frequency' ? 'Frequency' : 'Time' },
...traceNames.map((name, i) => ({
label: name,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
})),
];
const axes: uPlot.Axis[] = [
{
stroke: wfColors.axis,
grid: { stroke: wfColors.grid, width: 1 },
ticks: { stroke: wfColors.tick, width: 1 },
font: '11px system-ui, sans-serif',
values: (_u: uPlot, vals: number[]) =>
vals.map((v) => formatAxisValue(v, xType)),
},
{
stroke: wfColors.axis,
grid: { stroke: wfColors.grid, width: 1 },
ticks: { stroke: wfColors.tick, width: 1 },
font: '11px system-ui, sans-serif',
values: (_u: uPlot, vals: number[]) =>
vals.map((v) => formatAxisValue(v, yLabel)),
},
];
return {
width,
height,
series,
axes,
scales: {
x: xType === 'frequency' ? { distr: 3 } : {},
},
cursor: {
drag: { x: true, y: true, setScale: true },
},
legend: {
show: true,
},
};
}
function TransientPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const plotRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!plotRef.current || width <= 0) return;
const traceNames = Object.keys(waveform.y_data);
const xData = new Float64Array(waveform.x_data);
const data: uPlot.AlignedData = [
xData as unknown as number[],
...traceNames.map(
(name) =>
new Float64Array(waveform.y_data[name]) as unknown as number[],
),
];
// Determine y-axis type from variable types
const yType =
waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage';
const opts = buildOpts(waveform, width, height ?? 280, waveform.x_type, yType);
const u = new uPlot(opts, data, plotRef.current);
uplotRef.current = u;
return () => {
u.destroy();
uplotRef.current = null;
};
}, [waveform, width, height]);
return <div ref={plotRef} />;
}
function ACPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const magRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef<HTMLDivElement>(null);
const magPlotRef = useRef<uPlot | null>(null);
const phasePlotRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!magRef.current || !phaseRef.current || width <= 0) return;
const xData = new Float64Array(waveform.x_data);
const magData = waveform.y_magnitude_db || {};
const phaseData = waveform.y_phase_deg || {};
const magTraces = Object.keys(magData);
const phaseTraces = Object.keys(phaseData);
// Magnitude plot
if (magTraces.length > 0) {
const magSeries: uPlot.AlignedData = [
xData as unknown as number[],
...magTraces.map(
(name) =>
new Float64Array(magData[name]) as unknown as number[],
),
];
const magH = height ? Math.round(height * 0.55) : 220;
const magOpts = buildOpts(waveform, width, magH, 'frequency', 'dB');
magOpts.series = [
{ label: 'Frequency' },
...magTraces.map((name, i) => ({
label: `|${name}| (dB)`,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
})),
];
const u1 = new uPlot(magOpts, magSeries, magRef.current);
magPlotRef.current = u1;
}
// Phase plot
if (phaseTraces.length > 0) {
const phaseSeries: uPlot.AlignedData = [
xData as unknown as number[],
...phaseTraces.map(
(name) =>
new Float64Array(phaseData[name]) as unknown as number[],
),
];
const phaseH = height ? height - Math.round(height * 0.55) : 180;
const phaseOpts = buildOpts(
waveform,
width,
phaseH,
'frequency',
'degrees',
);
phaseOpts.series = [
{ label: 'Frequency' },
...phaseTraces.map((name, i) => ({
label: `Phase(${name})`,
stroke: TRACE_COLORS[i % TRACE_COLORS.length],
width: 2,
dash: [6, 3],
})),
];
const u2 = new uPlot(phaseOpts, phaseSeries, phaseRef.current);
phasePlotRef.current = u2;
}
return () => {
magPlotRef.current?.destroy();
phasePlotRef.current?.destroy();
magPlotRef.current = null;
phasePlotRef.current = null;
};
}, [waveform, width, height]);
return (
<div className="space-y-2">
<div ref={magRef} />
<div ref={phaseRef} />
</div>
);
}
/* Scope mode toggle icon — inline SVG of a simple oscilloscope shape */
function ScopeToggleIcon({ active }: { active: boolean }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke={active ? '#2dd4bf' : 'currentColor'}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{/* Monitor frame */}
<rect x="2" y="3" width="20" height="14" rx="2" />
{/* Sine wave trace */}
<path d="M5 12 C7 8, 9 8, 11 12 S15 16, 17 12 L19 12" />
{/* Stand */}
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
}
export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const [scopeMode, setScopeMode] = useState(() => {
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
catch { return false; }
});
const updateDimensions = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
setHeight(containerRef.current.clientHeight);
}
}, []);
useEffect(() => {
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [updateDimensions]);
const toggleScope = useCallback(() => {
setScopeMode(prev => {
const next = !prev;
try { localStorage.setItem('spicebook-scope-mode', String(next)); } catch { /* noop */ }
return next;
});
}, []);
const traceCount = Object.keys(waveform.y_data).length;
const isEmpty =
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
// In fullscreen, subtract toolbar + padding from available height
const plotWidth = isFullscreen ? Math.max(width - 32, 100) : width;
const plotHeight = isFullscreen ? Math.max(height - 80, 200) : undefined;
return (
<div
ref={containerRef}
className={cn(
'relative',
className,
isFullscreen && 'fixed inset-0 z-50 bg-sb-surface flex flex-col',
)}
>
{/* Control buttons */}
{!isEmpty && (
<div
className={cn(
'flex items-center gap-1 z-10',
isFullscreen
? 'px-4 py-2 justify-end border-b border-sb-border/50'
: 'absolute top-1 right-1',
)}
>
<button
type="button"
onClick={toggleFullscreen}
className="p-1 rounded text-sb-muted hover:text-sb-text hover:bg-sb-border/50 transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? (
<Minimize2 className="w-[18px] h-[18px]" />
) : (
<Maximize2 className="w-[18px] h-[18px]" />
)}
</button>
<button
type="button"
onClick={toggleScope}
className="p-1 rounded hover:bg-sb-border/50 transition-colors"
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
>
<ScopeToggleIcon active={scopeMode} />
</button>
</div>
)}
{/* Plot area */}
<div className={cn(isFullscreen && 'flex-1 min-h-0 px-4 pb-4')}>
{isEmpty ? (
<div className="text-sb-muted text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={plotWidth}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={plotWidth} height={plotHeight} />
) : (
<TransientPlot waveform={waveform} width={plotWidth} height={plotHeight} />
)}
</div>
</div>
);
}