Merge feature/scope-skin: Tektronix oscilloscope waveform viewer
This commit is contained in:
commit
ac82068b98
383
frontend/src/components/notebook/output/ScopeWaveformViewer.tsx
Normal file
383
frontend/src/components/notebook/output/ScopeWaveformViewer.tsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import type { WaveformData } from '../../../lib/types';
|
||||||
|
import {
|
||||||
|
formatEng,
|
||||||
|
SCOPE_TRACE_COLORS,
|
||||||
|
generate125Sequence,
|
||||||
|
nearest125,
|
||||||
|
step125,
|
||||||
|
arrayMinMax,
|
||||||
|
tracesMinMax,
|
||||||
|
} from '../../../lib/waveform-utils';
|
||||||
|
import uPlot from 'uplot';
|
||||||
|
import 'uplot/dist/uPlot.min.css';
|
||||||
|
import '../../../styles/scope-skin.css';
|
||||||
|
|
||||||
|
/* ── Constants ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const TIME_SEQ = generate125Sequence(-9, 1); // 1ns → 5s
|
||||||
|
const VOLT_SEQ = generate125Sequence(-3, 2); // 1mV → 50V
|
||||||
|
const FREQ_SEQ = generate125Sequence(0, 9); // 1Hz → 5GHz
|
||||||
|
const DB_SEQ = generate125Sequence(-1, 2); // 0.1dB/div → 50dB/div
|
||||||
|
const DEG_SEQ = [5, 10, 15, 20, 30, 45, 90, 180]; // degrees/div
|
||||||
|
|
||||||
|
const SKINS = {
|
||||||
|
'465': { brand: 'Tektronix', model: '465', sub: '', sections: ['Source', 'Volts/Div', 'Time/Div', 'Mode'] },
|
||||||
|
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Vert Ampl', 'Sweep', 'Mode'] },
|
||||||
|
} as const;
|
||||||
|
type SkinId = keyof typeof SKINS;
|
||||||
|
const SKIN_ORDER: SkinId[] = ['465', '545a'];
|
||||||
|
|
||||||
|
const NUM_H_DIVS = 10;
|
||||||
|
const NUM_V_DIVS = 8;
|
||||||
|
|
||||||
|
/* ── Safe localStorage ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function safeGetItem(key: string): string | null {
|
||||||
|
try { return localStorage.getItem(key); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSetItem(key: string, value: string): void {
|
||||||
|
try { localStorage.setItem(key, value); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function autoDiv(min: number, max: number, nDivs: number, seq: number[]): number {
|
||||||
|
const range = max - min;
|
||||||
|
if (range <= 0) return seq[Math.floor(seq.length / 2)];
|
||||||
|
const ideal = range / nDivs;
|
||||||
|
return nearest125(ideal, seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function divUnit(axisType: string): string {
|
||||||
|
switch (axisType) {
|
||||||
|
case 'time': return 's';
|
||||||
|
case 'frequency': return 'Hz';
|
||||||
|
case 'voltage': return 'V';
|
||||||
|
case 'current': return 'A';
|
||||||
|
case 'dB': return 'dB';
|
||||||
|
case 'degrees': case 'phase': return '\u00B0';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function xSeqForType(xType: string): number[] {
|
||||||
|
return xType === 'frequency' ? FREQ_SEQ : TIME_SEQ;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ySeqForType(yType: string): number[] {
|
||||||
|
if (yType === 'dB') return DB_SEQ;
|
||||||
|
if (yType === 'degrees' || yType === 'phase') return DEG_SEQ;
|
||||||
|
return VOLT_SEQ;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Props ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface ScopeWaveformViewerProps {
|
||||||
|
waveform: WaveformData;
|
||||||
|
width: number;
|
||||||
|
onExitScope: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Component ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function ScopeWaveformViewer({ waveform, width, onExitScope }: ScopeWaveformViewerProps) {
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
const uplotRef = useRef<uPlot | null>(null);
|
||||||
|
|
||||||
|
// Determine analysis type and trace names
|
||||||
|
const isAC = waveform.is_complex;
|
||||||
|
const analysisLabel = isAC ? 'AC' : 'TRAN';
|
||||||
|
|
||||||
|
// For AC, use magnitude traces; for transient, use y_data
|
||||||
|
const traceNames = useMemo(() => {
|
||||||
|
if (isAC) {
|
||||||
|
return waveform.y_magnitude_db ? Object.keys(waveform.y_magnitude_db) : [];
|
||||||
|
}
|
||||||
|
return Object.keys(waveform.y_data);
|
||||||
|
}, [waveform, isAC]);
|
||||||
|
|
||||||
|
const xType = waveform.x_type;
|
||||||
|
const yType = useMemo(() => {
|
||||||
|
if (isAC) return 'dB';
|
||||||
|
return waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage';
|
||||||
|
}, [waveform, isAC]);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [activeTrace, setActiveTrace] = useState(0);
|
||||||
|
const [xDiv, setXDiv] = useState<number | null>(null);
|
||||||
|
const [yDiv, setYDiv] = useState<number | null>(null);
|
||||||
|
const [skinId, setSkinId] = useState<SkinId>(() => {
|
||||||
|
const stored = safeGetItem('spicebook-scope-skin') as SkinId | null;
|
||||||
|
return stored && SKINS[stored] ? stored : '465';
|
||||||
|
});
|
||||||
|
|
||||||
|
const skin = SKINS[skinId];
|
||||||
|
|
||||||
|
// Compute data range using loop-based min/max (safe for large arrays)
|
||||||
|
const { xMin, xMax, yMin, yMax } = useMemo(() => {
|
||||||
|
const [xLo, xHi] = arrayMinMax(waveform.x_data);
|
||||||
|
const yData = isAC && waveform.y_magnitude_db
|
||||||
|
? waveform.y_magnitude_db
|
||||||
|
: waveform.y_data;
|
||||||
|
const [yLo, yHi] = tracesMinMax(yData);
|
||||||
|
return { xMin: xLo, xMax: xHi, yMin: yLo, yMax: yHi };
|
||||||
|
}, [waveform, isAC]);
|
||||||
|
|
||||||
|
// Initialize div values from data range
|
||||||
|
useEffect(() => {
|
||||||
|
setXDiv(autoDiv(xMin, xMax, NUM_H_DIVS, xSeqForType(xType)));
|
||||||
|
setYDiv(autoDiv(yMin, yMax, NUM_V_DIVS, ySeqForType(yType)));
|
||||||
|
}, [xMin, xMax, yMin, yMax, xType, yType]);
|
||||||
|
|
||||||
|
// Build and mount uPlot (only on data/size changes, NOT on trace/div changes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screenRef.current || width <= 0 || xDiv === null || yDiv === null) return;
|
||||||
|
|
||||||
|
const el = screenRef.current;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const plotW = rect.width || width;
|
||||||
|
const plotH = rect.height || Math.round(width * 3 / 5);
|
||||||
|
|
||||||
|
const xData = new Float64Array(waveform.x_data);
|
||||||
|
let yTraceData: Float64Array[];
|
||||||
|
let seriesLabels: string[];
|
||||||
|
|
||||||
|
if (isAC && waveform.y_magnitude_db) {
|
||||||
|
const mag = waveform.y_magnitude_db;
|
||||||
|
const names = Object.keys(mag);
|
||||||
|
yTraceData = names.map(n => new Float64Array(mag[n]));
|
||||||
|
seriesLabels = names.map(n => `|${n}|`);
|
||||||
|
} else {
|
||||||
|
const names = Object.keys(waveform.y_data);
|
||||||
|
yTraceData = names.map(n => new Float64Array(waveform.y_data[n]));
|
||||||
|
seriesLabels = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: uPlot.AlignedData = [
|
||||||
|
xData as unknown as number[],
|
||||||
|
...yTraceData.map(td => td as unknown as number[]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const series: uPlot.Series[] = [
|
||||||
|
{ label: xType === 'frequency' ? 'Frequency' : 'Time' },
|
||||||
|
...seriesLabels.map((label, i) => ({
|
||||||
|
label,
|
||||||
|
stroke: SCOPE_TRACE_COLORS[i % SCOPE_TRACE_COLORS.length],
|
||||||
|
width: 2,
|
||||||
|
show: i === activeTrace,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Center the view around the data midpoint using current div settings
|
||||||
|
const xMid = (xMin + xMax) / 2;
|
||||||
|
const yMid = (yMin + yMax) / 2;
|
||||||
|
const xRange = xDiv * NUM_H_DIVS;
|
||||||
|
const yRange = yDiv * NUM_V_DIVS;
|
||||||
|
|
||||||
|
const opts: uPlot.Options = {
|
||||||
|
width: plotW,
|
||||||
|
height: plotH,
|
||||||
|
series,
|
||||||
|
axes: [
|
||||||
|
{ show: false },
|
||||||
|
{ show: false },
|
||||||
|
],
|
||||||
|
scales: {
|
||||||
|
x: xType === 'frequency'
|
||||||
|
? { distr: 3, min: xMin, max: xMax }
|
||||||
|
: { min: xMid - xRange / 2, max: xMid + xRange / 2 },
|
||||||
|
y: { min: yMid - yRange / 2, max: yMid + yRange / 2 },
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
drag: { x: true, y: true, setScale: true },
|
||||||
|
},
|
||||||
|
legend: { show: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const u = new uPlot(opts, data, el);
|
||||||
|
uplotRef.current = u;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
u.destroy();
|
||||||
|
uplotRef.current = null;
|
||||||
|
};
|
||||||
|
// Intentionally exclude activeTrace, xDiv, yDiv — handled by dedicated effects below
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [waveform, width, isAC, xMin, xMax, yMin, yMax, xType]);
|
||||||
|
|
||||||
|
// Update trace visibility via setSeries (no chart rebuild)
|
||||||
|
useEffect(() => {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
if (!u) return;
|
||||||
|
for (let i = 1; i < u.series.length; i++) {
|
||||||
|
u.setSeries(i, { show: i - 1 === activeTrace });
|
||||||
|
}
|
||||||
|
}, [activeTrace]);
|
||||||
|
|
||||||
|
// Update X/Y scale via setScale (no chart rebuild)
|
||||||
|
useEffect(() => {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
if (!u || xDiv === null || yDiv === null) return;
|
||||||
|
|
||||||
|
const xMid = (xMin + xMax) / 2;
|
||||||
|
const yMid = (yMin + yMax) / 2;
|
||||||
|
|
||||||
|
if (xType !== 'frequency') {
|
||||||
|
const xRange = xDiv * NUM_H_DIVS;
|
||||||
|
u.setScale('x', { min: xMid - xRange / 2, max: xMid + xRange / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const yRange = yDiv * NUM_V_DIVS;
|
||||||
|
u.setScale('y', { min: yMid - yRange / 2, max: yMid + yRange / 2 });
|
||||||
|
}, [xDiv, yDiv, xMin, xMax, yMin, yMax, xType]);
|
||||||
|
|
||||||
|
// Knob handlers
|
||||||
|
const cycleTrace = useCallback(() => {
|
||||||
|
setActiveTrace(prev => (prev + 1) % traceNames.length);
|
||||||
|
}, [traceNames.length]);
|
||||||
|
|
||||||
|
const stepXDiv = useCallback(() => {
|
||||||
|
setXDiv(prev => {
|
||||||
|
if (prev === null) return prev;
|
||||||
|
const seq = xSeqForType(xType);
|
||||||
|
const next = step125(prev, seq, 1);
|
||||||
|
if (next === prev) return seq[0];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [xType]);
|
||||||
|
|
||||||
|
const stepYDiv = useCallback(() => {
|
||||||
|
setYDiv(prev => {
|
||||||
|
if (prev === null) return prev;
|
||||||
|
const seq = ySeqForType(yType);
|
||||||
|
const next = step125(prev, seq, 1);
|
||||||
|
if (next === prev) return seq[0];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [yType]);
|
||||||
|
|
||||||
|
const cycleSkin = useCallback(() => {
|
||||||
|
setSkinId(prev => {
|
||||||
|
const idx = SKIN_ORDER.indexOf(prev);
|
||||||
|
const next = SKIN_ORDER[(idx + 1) % SKIN_ORDER.length];
|
||||||
|
safeSetItem('spicebook-scope-skin', next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Knob rotation from trace index
|
||||||
|
const sourceRotation = traceNames.length > 1
|
||||||
|
? (activeTrace / traceNames.length) * 270 - 135
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const currentTraceName = traceNames[activeTrace] || '\u2014';
|
||||||
|
const xDivLabel = xDiv !== null ? formatEng(xDiv, divUnit(xType) + '/div') : '\u2014';
|
||||||
|
const yDivLabel = yDiv !== null ? formatEng(yDiv, divUnit(yType) + '/div') : '\u2014';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scope-skin-active">
|
||||||
|
<div className={`scope-frame${skinId === '545a' ? ' scope-hw-545a' : ''}`}>
|
||||||
|
{/* Brand bar */}
|
||||||
|
<div className="scope-brand">
|
||||||
|
<span className="scope-brand-name">{skin.brand}</span>
|
||||||
|
<div className="scope-brand-right">
|
||||||
|
<button
|
||||||
|
className="scope-brand-model"
|
||||||
|
type="button"
|
||||||
|
onClick={cycleSkin}
|
||||||
|
aria-label="Switch oscilloscope model"
|
||||||
|
>
|
||||||
|
{skin.model}
|
||||||
|
</button>
|
||||||
|
{skin.sub && <span className="scope-brand-sub">{skin.sub}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CRT bay */}
|
||||||
|
<div className="scope-crt-bay">
|
||||||
|
<div className="scope-screen" ref={screenRef}>
|
||||||
|
{/* uPlot mounts here via ref */}
|
||||||
|
<div className="scope-graticule" />
|
||||||
|
<div className="scope-scanlines" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Digital readout */}
|
||||||
|
<div className="scope-readout">
|
||||||
|
<span className="scope-readout-item">{currentTraceName}</span>
|
||||||
|
<span className="scope-readout-divider">|</span>
|
||||||
|
<span className="scope-readout-item">{xDivLabel}</span>
|
||||||
|
<span className="scope-readout-divider">|</span>
|
||||||
|
<span className="scope-readout-item">{yDivLabel}</span>
|
||||||
|
<span className="scope-readout-divider">|</span>
|
||||||
|
<span className="scope-readout-item">{analysisLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control panel */}
|
||||||
|
<div className="scope-panel">
|
||||||
|
<div className="scope-controls-row">
|
||||||
|
{/* Source knob */}
|
||||||
|
<div className="scope-section">
|
||||||
|
<span className="scope-section-label">{skin.sections[0]}</span>
|
||||||
|
<button
|
||||||
|
className="scope-knob"
|
||||||
|
type="button"
|
||||||
|
onClick={cycleTrace}
|
||||||
|
style={{ '--knob-rotation': `${sourceRotation}deg` } as React.CSSProperties}
|
||||||
|
aria-label={`Trace: ${currentTraceName} (${activeTrace + 1} of ${traceNames.length})`}
|
||||||
|
/>
|
||||||
|
<span className="scope-knob-label">{currentTraceName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volts/Div knob */}
|
||||||
|
<div className="scope-section">
|
||||||
|
<span className="scope-section-label">{skin.sections[1]}</span>
|
||||||
|
<button
|
||||||
|
className="scope-knob"
|
||||||
|
type="button"
|
||||||
|
onClick={stepYDiv}
|
||||||
|
aria-label={`Vertical scale: ${yDivLabel}`}
|
||||||
|
/>
|
||||||
|
<span className="scope-knob-label">{yDivLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time/Div knob */}
|
||||||
|
<div className="scope-section">
|
||||||
|
<span className="scope-section-label">{skin.sections[2]}</span>
|
||||||
|
<button
|
||||||
|
className="scope-knob"
|
||||||
|
type="button"
|
||||||
|
onClick={stepXDiv}
|
||||||
|
aria-label={`Horizontal scale: ${xDivLabel}`}
|
||||||
|
/>
|
||||||
|
<span className="scope-knob-label">{xDivLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode badge */}
|
||||||
|
<div className="scope-section">
|
||||||
|
<span className="scope-section-label">{skin.sections[3]}</span>
|
||||||
|
<button
|
||||||
|
className="scope-mode-badge"
|
||||||
|
type="button"
|
||||||
|
onClick={onExitScope}
|
||||||
|
aria-label="Exit scope mode"
|
||||||
|
>
|
||||||
|
{analysisLabel}
|
||||||
|
</button>
|
||||||
|
<div className="scope-led" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ventilation holes (545A only) */}
|
||||||
|
<div className="scope-vent-holes" aria-hidden="true">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<div key={i} className="scope-vent-hole" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import type { WaveformData } from '../../../lib/types';
|
import type { WaveformData } from '../../../lib/types';
|
||||||
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
|
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
|
||||||
|
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
|
||||||
|
|
||||||
// uPlot is a vanilla JS lib; import it and its CSS
|
// uPlot is a vanilla JS lib; import it and its CSS
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
@ -207,9 +208,38 @@ function ACPlot({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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) {
|
export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
|
const [scopeMode, setScopeMode] = useState(() => {
|
||||||
|
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
const updateWidth = useCallback(() => {
|
const updateWidth = useCallback(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
@ -226,16 +256,43 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [updateWidth]);
|
}, [updateWidth]);
|
||||||
|
|
||||||
|
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 traceCount = Object.keys(waveform.y_data).length;
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
|
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={className}>
|
<div ref={containerRef} className={`relative ${className || ''}`}>
|
||||||
|
{/* Scope toggle button */}
|
||||||
|
{!isEmpty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleScope}
|
||||||
|
className="absolute top-1 right-1 z-10 p-1 rounded hover:bg-slate-700/50 transition-colors"
|
||||||
|
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
|
||||||
|
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
|
||||||
|
>
|
||||||
|
<ScopeToggleIcon active={scopeMode} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="text-slate-500 text-sm py-4 text-center">
|
<div className="text-slate-500 text-sm py-4 text-center">
|
||||||
No waveform data to display.
|
No waveform data to display.
|
||||||
</div>
|
</div>
|
||||||
|
) : scopeMode ? (
|
||||||
|
<ScopeWaveformViewer
|
||||||
|
waveform={waveform}
|
||||||
|
width={width}
|
||||||
|
onExitScope={toggleScope}
|
||||||
|
/>
|
||||||
) : waveform.is_complex ? (
|
) : waveform.is_complex ? (
|
||||||
<ACPlot waveform={waveform} width={width} />
|
<ACPlot waveform={waveform} width={width} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -104,3 +104,83 @@ export const TRACE_COLORS = [
|
|||||||
export function traceColor(index: number): string {
|
export function traceColor(index: number): string {
|
||||||
return TRACE_COLORS[index % TRACE_COLORS.length];
|
return TRACE_COLORS[index % TRACE_COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teal phosphor palette for scope mode.
|
||||||
|
* Real CRTs use a single phosphor color — traces are distinguished
|
||||||
|
* by brightness/alpha rather than hue. */
|
||||||
|
export const SCOPE_TRACE_COLORS = [
|
||||||
|
'#2dd4bf', // bright teal (primary trace)
|
||||||
|
'#5eead4', // lighter teal (second trace)
|
||||||
|
'#14b8a6', // medium teal
|
||||||
|
'#0d9488', // darker teal
|
||||||
|
'#99f6e4', // very light teal
|
||||||
|
'#0f766e', // deep teal
|
||||||
|
'#2dd4bf80', // bright teal 50% (ghost trace)
|
||||||
|
'#5eead480', // light teal 50%
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Standard oscilloscope 1-2-5 step sequence.
|
||||||
|
* Returns sorted array of values spanning the full SI range. */
|
||||||
|
export function generate125Sequence(minExp: number, maxExp: number): number[] {
|
||||||
|
const steps: number[] = [];
|
||||||
|
for (let exp = minExp; exp <= maxExp; exp++) {
|
||||||
|
const base = Math.pow(10, exp);
|
||||||
|
steps.push(1 * base, 2 * base, 5 * base);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the nearest 1-2-5 step for a given value.
|
||||||
|
* Guards against zero, negative, and NaN inputs. */
|
||||||
|
export function nearest125(value: number, sequence: number[]): number {
|
||||||
|
if (!isFinite(value) || value <= 0) return sequence[Math.floor(sequence.length / 2)];
|
||||||
|
let best = sequence[0];
|
||||||
|
let bestDist = Math.abs(Math.log10(value) - Math.log10(best));
|
||||||
|
for (const s of sequence) {
|
||||||
|
const dist = Math.abs(Math.log10(value) - Math.log10(s));
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step up or down in a 1-2-5 sequence.
|
||||||
|
* Uses tolerance-based search to handle floating-point inexactness. */
|
||||||
|
export function step125(current: number, sequence: number[], direction: 1 | -1): number {
|
||||||
|
const idx = sequence.findIndex(s =>
|
||||||
|
s === current || Math.abs(s - current) < Math.abs(current) * 1e-9,
|
||||||
|
);
|
||||||
|
if (idx === -1) return nearest125(current, sequence);
|
||||||
|
const next = idx + direction;
|
||||||
|
if (next < 0) return sequence[0];
|
||||||
|
if (next >= sequence.length) return sequence[sequence.length - 1];
|
||||||
|
return sequence[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute min and max of a numeric array in a single pass.
|
||||||
|
* Safe for arbitrarily large arrays (no stack overflow from spread). */
|
||||||
|
export function arrayMinMax(arr: ArrayLike<number>): [number, number] {
|
||||||
|
if (arr.length === 0) return [0, 0];
|
||||||
|
let min = arr[0];
|
||||||
|
let max = arr[0];
|
||||||
|
for (let i = 1; i < arr.length; i++) {
|
||||||
|
if (arr[i] < min) min = arr[i];
|
||||||
|
if (arr[i] > max) max = arr[i];
|
||||||
|
}
|
||||||
|
return [min, max];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute min and max across multiple trace arrays without intermediate allocation. */
|
||||||
|
export function tracesMinMax(traces: Record<string, number[]>): [number, number] {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const arr of Object.values(traces)) {
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (arr[i] < min) min = arr[i];
|
||||||
|
if (arr[i] > max) max = arr[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isFinite(min) ? [min, max] : [-1, 1];
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,14 @@
|
|||||||
--color-wf-tick: #334155;
|
--color-wf-tick: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scope mode waveform overrides */
|
||||||
|
:root {
|
||||||
|
--color-wf-scope-axis: #2dd4bf;
|
||||||
|
--color-wf-scope-grid: rgba(45, 212, 191, 0.07);
|
||||||
|
--color-wf-scope-tick: rgba(45, 212, 191, 0.15);
|
||||||
|
--color-wf-scope-bg: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base resets */
|
/* Base resets */
|
||||||
html {
|
html {
|
||||||
background-color: var(--color-sb-bg);
|
background-color: var(--color-sb-bg);
|
||||||
|
|||||||
484
frontend/src/styles/scope-skin.css
Normal file
484
frontend/src/styles/scope-skin.css
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
/* Tektronix oscilloscope skin for SpiceBook waveform viewer
|
||||||
|
* Ported from mcltspice docs OscilloscopeDisplay.astro + oscilloscope.css
|
||||||
|
* Adapted: landscape CRT (5:3), 10×8 graticule, no audio/easter-egg styles,
|
||||||
|
* SpiceBook font vars, .scope-skin-active scoping, uPlot overrides */
|
||||||
|
|
||||||
|
/* ── Outer chassis ───────────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-frame {
|
||||||
|
--scope-teal: #2dd4bf;
|
||||||
|
--scope-teal-dim: rgba(45, 212, 191, 0.12);
|
||||||
|
--scope-teal-glow: rgba(45, 212, 191, 0.18);
|
||||||
|
--scope-panel: #b5a48a;
|
||||||
|
--scope-panel-light: #c7b89e;
|
||||||
|
--scope-panel-dark: #9e8f78;
|
||||||
|
--scope-crt-bg: #0a0a0a;
|
||||||
|
--scope-label: #3b3428;
|
||||||
|
--scope-knob: #2a2a2d;
|
||||||
|
--scope-knob-ring: #1e1e20;
|
||||||
|
--scope-section-line: rgba(59, 52, 40, 0.25);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
/* subtle metallic grain */
|
||||||
|
url("data:image/svg+xml,%3Csvg width='4' height='4' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1' x='0' y='0' fill='rgba(0,0,0,0.03)'/%3E%3Crect width='1' height='1' x='2' y='2' fill='rgba(255,255,255,0.02)'/%3E%3C/svg%3E"),
|
||||||
|
linear-gradient(175deg, var(--scope-panel-light), var(--scope-panel), var(--scope-panel-dark));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.35),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top brand bar ───────────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px 6px;
|
||||||
|
border-bottom: 1px solid var(--scope-section-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-brand-name {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--scope-label);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-brand-model {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--scope-label);
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skin picker (model name → clickable) ──────────────── */
|
||||||
|
.scope-skin-active .scope-brand-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active button.scope-brand-model {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: inherit;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active button.scope-brand-model:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active button.scope-brand-model:focus-visible {
|
||||||
|
outline: 2px solid var(--scope-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-brand-sub {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--scope-label);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CRT bay (recessed dark area) ────────────────────────── */
|
||||||
|
.scope-skin-active .scope-crt-bay {
|
||||||
|
margin: 10px 12px 0;
|
||||||
|
background: #1a1816;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 6px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CRT screen ──────────────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-screen {
|
||||||
|
position: relative;
|
||||||
|
background: var(--scope-crt-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 5 / 3;
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px var(--scope-teal-glow),
|
||||||
|
inset 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* uPlot chart fills the CRT screen */
|
||||||
|
.scope-skin-active .scope-screen .uplot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Graticule overlay (10 horizontal × 8 vertical) ──────── */
|
||||||
|
.scope-skin-active .scope-graticule {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
/* 10 vertical divisions (10% each), 8 horizontal divisions (12.5% each) */
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
transparent calc(10% - 0.5px),
|
||||||
|
rgba(45, 212, 191, 0.07) calc(10% - 0.5px),
|
||||||
|
rgba(45, 212, 191, 0.07) calc(10% + 0.5px),
|
||||||
|
transparent calc(10% + 0.5px)
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent calc(12.5% - 0.5px),
|
||||||
|
rgba(45, 212, 191, 0.07) calc(12.5% - 0.5px),
|
||||||
|
rgba(45, 212, 191, 0.07) calc(12.5% + 0.5px),
|
||||||
|
transparent calc(12.5% + 0.5px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center crosshair tick marks */
|
||||||
|
.scope-skin-active .scope-graticule::before,
|
||||||
|
.scope-skin-active .scope-graticule::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-graticule::before {
|
||||||
|
/* horizontal center tick */
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 8px);
|
||||||
|
width: 16px;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(45, 212, 191, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-graticule::after {
|
||||||
|
/* vertical center tick */
|
||||||
|
left: 50%;
|
||||||
|
top: calc(50% - 8px);
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: rgba(45, 212, 191, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scanline overlay ────────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-scanlines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.04) 2px,
|
||||||
|
rgba(0, 0, 0, 0.04) 4px
|
||||||
|
);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Digital readout bar ─────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-readout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
margin: 0 12px;
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--scope-teal);
|
||||||
|
text-shadow: 0 0 4px var(--scope-teal-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-readout-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-readout-divider {
|
||||||
|
color: rgba(45, 212, 191, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Control panel area ──────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-panel {
|
||||||
|
padding: 8px 12px 6px;
|
||||||
|
border-top: 1px solid var(--scope-section-line);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Control section (labeled group) ─────────────────────── */
|
||||||
|
.scope-skin-active .scope-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section divider lines */
|
||||||
|
.scope-skin-active .scope-section + .scope-section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--scope-section-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-section-label {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--scope-label);
|
||||||
|
opacity: 0.88;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rotary knob ─────────────────────────────────────────── */
|
||||||
|
/* Reset native button styling so knobs render as pure circles */
|
||||||
|
.scope-skin-active button.scope-knob {
|
||||||
|
appearance: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 40% 35%, #3a3a3e, var(--scope-knob));
|
||||||
|
border: 2px solid var(--scope-knob-ring);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.35),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.06);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
transform: rotate(var(--knob-rotation, 0deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knob indicator line */
|
||||||
|
.scope-skin-active .scope-knob::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1.5px;
|
||||||
|
height: 7px;
|
||||||
|
background: #d4d0c8;
|
||||||
|
border-radius: 1px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob:hover {
|
||||||
|
transform: rotate(var(--knob-rotation, 0deg)) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob:active {
|
||||||
|
transform: rotate(var(--knob-rotation, 0deg)) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob:focus-visible {
|
||||||
|
outline: 2px solid var(--scope-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob-label {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.45rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--scope-label);
|
||||||
|
opacity: 0.78;
|
||||||
|
line-height: 1;
|
||||||
|
max-width: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mode badge (TRAN / AC) ──────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-mode-badge {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--scope-knob);
|
||||||
|
border: 2px solid var(--scope-knob-ring);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--scope-teal);
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, box-shadow 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 6px var(--scope-teal-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-mode-badge:hover {
|
||||||
|
color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-mode-badge:focus-visible {
|
||||||
|
outline: 2px solid var(--scope-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Power LED ───────────────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-led {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--scope-teal);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||||
|
box-shadow: 0 0 6px var(--scope-teal-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ventilation holes (545A only) ──────────────────────── */
|
||||||
|
.scope-skin-active .scope-vent-holes {
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-vent-hole {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(0, 0, 0, 0.4) 40%, rgba(0, 0, 0, 0.15) 100%);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tektronix Type 545A skin (1959) ─────────────────────── */
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a {
|
||||||
|
--scope-panel: #4a6e64;
|
||||||
|
--scope-panel-light: #5a7e72;
|
||||||
|
--scope-panel-dark: #3a5a50;
|
||||||
|
--scope-label: #e0d8c8;
|
||||||
|
--scope-knob: #1c1814;
|
||||||
|
--scope-knob-ring: #141210;
|
||||||
|
--scope-section-line: rgba(224, 216, 200, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hammertone texture */
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg width='8' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='2' cy='2' r='1.2' fill='rgba(0,0,0,0.06)'/%3E%3Ccircle cx='6' cy='5' r='0.8' fill='rgba(255,255,255,0.04)'/%3E%3Ccircle cx='4' cy='7' r='1' fill='rgba(0,0,0,0.04)'/%3E%3Ccircle cx='7' cy='1' r='0.6' fill='rgba(255,255,255,0.03)'/%3E%3C/svg%3E"),
|
||||||
|
linear-gradient(175deg, var(--scope-panel-light), var(--scope-panel), var(--scope-panel-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deeper CRT bay recess */
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-crt-bay {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 3px 8px rgba(0, 0, 0, 0.6),
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger Bakelite-era knobs */
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-knob {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: radial-gradient(circle at 40% 35%, #2e2218, var(--scope-knob));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-knob::after {
|
||||||
|
height: 8px;
|
||||||
|
background: #c8b890;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 545A shows vent holes */
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-vent-holes {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── uPlot CRT overrides ─────────────────────────────────── */
|
||||||
|
.scope-skin-active .scope-screen .uplot canvas {
|
||||||
|
filter: drop-shadow(0 0 3px var(--scope-teal-glow));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-screen .u-legend {
|
||||||
|
display: none; /* legend info is in the digital readout bar */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide uPlot axes — the graticule replaces them */
|
||||||
|
.scope-skin-active .scope-screen .u-axis {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion ──────────────────────────────────────── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scope-skin-active .scope-scanlines {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────── */
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.scope-skin-active .scope-knob {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-knob::after {
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-readout {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-knob {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-frame.scope-hw-545a .scope-knob::after {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-skin-active .scope-vent-hole {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user