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.
351 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|