Compare commits
3 Commits
579f90487d
...
ac82068b98
| Author | SHA1 | Date | |
|---|---|---|---|
| ac82068b98 | |||
| 22d3e903db | |||
| b497d57890 |
@ -56,6 +56,16 @@ class ActiveLayout:
|
||||
unplaced: list[SpiceComponent]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlacedComponent:
|
||||
"""A component placed in the grid with recorded terminal positions."""
|
||||
|
||||
comp: SpiceComponent
|
||||
element: object # SchemDraw placed element
|
||||
column: int
|
||||
terminal_positions: dict[int, tuple[float, float]] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Ground / Supply Detection ───────────────────────────────────
|
||||
|
||||
GROUND_NAMES = {"0", "gnd"}
|
||||
@ -678,50 +688,396 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
|
||||
# ── Grid Layout Renderer ──────────────────────────────────────
|
||||
# ── Grid Layout: Constants & Helpers ─────────────────────────
|
||||
|
||||
# Vertical tier positions (schemdraw y-axis: up is positive)
|
||||
_GRID_SUPPLY_Y = 0.0
|
||||
_GRID_UPPER_Y = -3.0
|
||||
_GRID_MID_Y = -5.5
|
||||
_GRID_LOWER_Y = -8.0
|
||||
_GRID_GROUND_Y = -11.0
|
||||
_GRID_X_SPACING = 6.0
|
||||
_GRID_MIN_COMP_LEN = 2.0 # minimum component length to avoid zero-height placement
|
||||
|
||||
|
||||
def _classify_node_tiers(
|
||||
node_map: dict[str, list[tuple[SpiceComponent, int]]],
|
||||
) -> dict[str, float]:
|
||||
"""Assign vertical Y positions to nodes via BFS distance from ground/supply.
|
||||
|
||||
Ground and supply nodes are pinned to fixed tiers. Signal nodes get
|
||||
Y-positions blended between supply (top) and ground (bottom) based on
|
||||
their BFS hop distance from each rail, then snapped to the nearest
|
||||
discrete tier for clean vertical alignment.
|
||||
"""
|
||||
from collections import deque
|
||||
|
||||
tiers: dict[str, float] = {}
|
||||
snap_levels = [_GRID_SUPPLY_Y, _GRID_UPPER_Y, _GRID_MID_Y, _GRID_LOWER_Y, _GRID_GROUND_Y]
|
||||
|
||||
# Pin ground and supply nodes
|
||||
for node in node_map:
|
||||
if _is_ground(node):
|
||||
tiers[node] = _GRID_GROUND_Y
|
||||
elif _is_supply(node):
|
||||
tiers[node] = _GRID_SUPPLY_Y
|
||||
|
||||
# BFS from ground nodes
|
||||
dist_from_gnd: dict[str, int] = {}
|
||||
queue: deque[tuple[str, int]] = deque()
|
||||
for node in node_map:
|
||||
if _is_ground(node):
|
||||
dist_from_gnd[node] = 0
|
||||
queue.append((node, 0))
|
||||
while queue:
|
||||
current, dist = queue.popleft()
|
||||
for comp, idx in node_map.get(current, []):
|
||||
for i, other_node in enumerate(comp.nodes):
|
||||
if i != idx and other_node not in dist_from_gnd:
|
||||
dist_from_gnd[other_node] = dist + 1
|
||||
queue.append((other_node, dist + 1))
|
||||
|
||||
# BFS from supply nodes
|
||||
dist_from_sup: dict[str, int] = {}
|
||||
queue = deque()
|
||||
for node in node_map:
|
||||
if _is_supply(node):
|
||||
dist_from_sup[node] = 0
|
||||
queue.append((node, 0))
|
||||
while queue:
|
||||
current, dist = queue.popleft()
|
||||
for comp, idx in node_map.get(current, []):
|
||||
for i, other_node in enumerate(comp.nodes):
|
||||
if i != idx and other_node not in dist_from_sup:
|
||||
dist_from_sup[other_node] = dist + 1
|
||||
queue.append((other_node, dist + 1))
|
||||
|
||||
# Assign signal nodes: blend by relative BFS distance, snap to tier
|
||||
for node in node_map:
|
||||
if node in tiers:
|
||||
continue
|
||||
dg = dist_from_gnd.get(node, 999)
|
||||
ds = dist_from_sup.get(node, 999)
|
||||
if ds == 999 and dg == 999:
|
||||
continuous_y = _GRID_MID_Y
|
||||
elif ds == 999:
|
||||
continuous_y = _GRID_LOWER_Y
|
||||
elif dg == 999:
|
||||
continuous_y = _GRID_UPPER_Y
|
||||
else:
|
||||
ratio = ds / (ds + dg)
|
||||
continuous_y = _GRID_SUPPLY_Y + ratio * (_GRID_GROUND_Y - _GRID_SUPPLY_Y)
|
||||
tiers[node] = min(snap_levels, key=lambda t: abs(t - continuous_y))
|
||||
|
||||
return tiers
|
||||
|
||||
|
||||
def _assign_columns(
|
||||
components: list[SpiceComponent],
|
||||
node_map: dict[str, list[tuple[SpiceComponent, int]]],
|
||||
) -> list[SpiceComponent]:
|
||||
"""Order placeable components left-to-right via BFS through signal nodes.
|
||||
|
||||
Supply sources (DC V-source between supply rail and ground) are filtered
|
||||
out — they become rail symbols instead of drawn components.
|
||||
"""
|
||||
from collections import deque
|
||||
|
||||
placeable = [c for c in components if not _is_supply_source(c)]
|
||||
if not placeable:
|
||||
return []
|
||||
|
||||
# Build component adjacency through shared signal nodes
|
||||
comp_names = {c.name for c in placeable}
|
||||
comp_lookup = {c.name: c for c in placeable}
|
||||
adj: dict[str, set[str]] = {c.name: set() for c in placeable}
|
||||
|
||||
for node, connections in node_map.items():
|
||||
if _is_ground(node) or _is_supply(node):
|
||||
continue
|
||||
node_comps = [c.name for c, _ in connections if c.name in comp_names]
|
||||
for i, a in enumerate(node_comps):
|
||||
for b in node_comps[i + 1:]:
|
||||
adj[a].add(b)
|
||||
adj[b].add(a)
|
||||
|
||||
# BFS from first voltage source (or first component if none)
|
||||
start = next((c for c in placeable if c.prefix == "V"), None)
|
||||
if start is None:
|
||||
start = placeable[0]
|
||||
|
||||
ordered: list[str] = []
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque([start.name])
|
||||
visited.add(start.name)
|
||||
|
||||
while queue:
|
||||
name = queue.popleft()
|
||||
ordered.append(name)
|
||||
for neighbor in sorted(adj.get(name, [])):
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append(neighbor)
|
||||
|
||||
# Append any disconnected components not reached by BFS
|
||||
for c in placeable:
|
||||
if c.name not in visited:
|
||||
ordered.append(c.name)
|
||||
|
||||
return [comp_lookup[name] for name in ordered]
|
||||
|
||||
|
||||
def _get_terminal_positions(
|
||||
placed_elem: object,
|
||||
comp: SpiceComponent,
|
||||
) -> dict[int, tuple[float, float]]:
|
||||
"""Extract (x, y) terminal positions from a placed SchemDraw element."""
|
||||
positions: dict[int, tuple[float, float]] = {}
|
||||
|
||||
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
|
||||
anchors = (
|
||||
["collector", "base", "emitter"]
|
||||
if comp.prefix == "Q"
|
||||
else ["drain", "gate", "source"]
|
||||
)
|
||||
for i, anchor_name in enumerate(anchors):
|
||||
if hasattr(placed_elem, anchor_name):
|
||||
pos = getattr(placed_elem, anchor_name)
|
||||
positions[i] = (float(pos[0]), float(pos[1]))
|
||||
else:
|
||||
if hasattr(placed_elem, "start"):
|
||||
positions[0] = (float(placed_elem.start[0]), float(placed_elem.start[1]))
|
||||
if hasattr(placed_elem, "end"):
|
||||
idx = min(1, len(comp.nodes) - 1)
|
||||
positions[idx] = (float(placed_elem.end[0]), float(placed_elem.end[1]))
|
||||
|
||||
return positions
|
||||
|
||||
|
||||
def _find_supply_value(
|
||||
components: list[SpiceComponent],
|
||||
supply_node: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Find the DC voltage value for a supply rail. Returns (source_name, formatted_value)."""
|
||||
for comp in components:
|
||||
if comp.prefix == "V" and len(comp.nodes) == 2:
|
||||
n0, n1 = comp.nodes
|
||||
if (n0.lower() == supply_node.lower() and _is_ground(n1)) or \
|
||||
(n1.lower() == supply_node.lower() and _is_ground(n0)):
|
||||
return (comp.name, _format_value(comp.value))
|
||||
return ("", "")
|
||||
|
||||
|
||||
def _draw_ground_rail(d, positions: list[tuple[float, float]]) -> None:
|
||||
"""Draw horizontal ground bus with vertical stubs and Ground symbol."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
if not positions:
|
||||
return
|
||||
|
||||
sorted_pos = sorted(positions, key=lambda p: p[0])
|
||||
|
||||
# Horizontal bus line across all ground-connected terminals
|
||||
if len(sorted_pos) > 1:
|
||||
d.add(elm.Line().at(sorted_pos[0]).to(sorted_pos[-1]))
|
||||
|
||||
# Vertical stubs from each terminal down to the bus
|
||||
for px, py in sorted_pos:
|
||||
if abs(py - _GRID_GROUND_Y) > 0.1:
|
||||
d.add(elm.Line().at((px, py)).to((px, _GRID_GROUND_Y)))
|
||||
|
||||
# Ground symbol at the bus midpoint
|
||||
mid_x = sum(p[0] for p in sorted_pos) / len(sorted_pos)
|
||||
d.add(elm.Ground().at((mid_x, _GRID_GROUND_Y)))
|
||||
|
||||
|
||||
def _draw_supply_rail(
|
||||
d,
|
||||
positions: list[tuple[float, float]],
|
||||
label: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Draw horizontal supply bus with vertical stubs and Vdd symbol."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
if not positions:
|
||||
return
|
||||
|
||||
sorted_pos = sorted(positions, key=lambda p: p[0])
|
||||
|
||||
if len(sorted_pos) > 1:
|
||||
d.add(elm.Line().at(sorted_pos[0]).to(sorted_pos[-1]))
|
||||
|
||||
for px, py in sorted_pos:
|
||||
if abs(py - _GRID_SUPPLY_Y) > 0.1:
|
||||
d.add(elm.Line().at((px, py)).to((px, _GRID_SUPPLY_Y)))
|
||||
|
||||
mid_x = sum(p[0] for p in sorted_pos) / len(sorted_pos)
|
||||
rail_label = label.upper()
|
||||
if value:
|
||||
rail_label += f" {value}"
|
||||
d.add(elm.Vdd().at((mid_x, _GRID_SUPPLY_Y)).label(rail_label))
|
||||
|
||||
|
||||
def _draw_signal_wire(
|
||||
d,
|
||||
pos_a: tuple[float, float],
|
||||
pos_b: tuple[float, float],
|
||||
) -> None:
|
||||
"""Draw an L-shaped or straight wire between two terminal positions."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
x1, y1 = pos_a
|
||||
x2, y2 = pos_b
|
||||
|
||||
# Skip near-zero-length wires
|
||||
if abs(x1 - x2) < 0.1 and abs(y1 - y2) < 0.1:
|
||||
return
|
||||
|
||||
if abs(x1 - x2) < 0.1 or abs(y1 - y2) < 0.1:
|
||||
# Straight wire (aligned on one axis)
|
||||
d.add(elm.Line().at((x1, y1)).to((x2, y2)))
|
||||
else:
|
||||
# L-shaped: horizontal first, then vertical
|
||||
d.add(elm.Line().at((x1, y1)).to((x2, y1)))
|
||||
d.add(elm.Line().at((x2, y1)).to((x2, y2)))
|
||||
|
||||
|
||||
def _draw_star_wires(
|
||||
d,
|
||||
positions: list[tuple[float, float]],
|
||||
) -> None:
|
||||
"""Draw star-topology wiring with junction dot for 3+ connections."""
|
||||
import schemdraw.elements as elm
|
||||
|
||||
if len(positions) < 2:
|
||||
return
|
||||
|
||||
# Junction at the median position (reduces total wire length)
|
||||
xs = sorted(p[0] for p in positions)
|
||||
ys = sorted(p[1] for p in positions)
|
||||
jx = xs[len(xs) // 2]
|
||||
jy = ys[len(ys) // 2]
|
||||
|
||||
for px, py in positions:
|
||||
if abs(px - jx) < 0.1 and abs(py - jy) < 0.1:
|
||||
continue
|
||||
_draw_signal_wire(d, (px, py), (jx, jy))
|
||||
|
||||
d.add(elm.Dot().at((jx, jy)))
|
||||
|
||||
|
||||
# ── Grid Layout Renderer ────────────────────────────────────
|
||||
|
||||
|
||||
def _render_grid(parsed: ParsedNetlist) -> str:
|
||||
"""Render components in a labeled grid layout.
|
||||
"""Render components in a wire-connected grid layout.
|
||||
|
||||
Used for complex circuits where topological layout isn't feasible.
|
||||
Components are arranged in columns by type with terminal node labels.
|
||||
Places components vertically between BFS-classified node tiers, then
|
||||
routes wires between terminals sharing the same net: power/ground get
|
||||
horizontal bus rails with symbols, 2-connection signals get L-shaped
|
||||
wires, and 3+ connections get star wiring with junction dots.
|
||||
"""
|
||||
import schemdraw
|
||||
import schemdraw.elements as elm
|
||||
|
||||
node_map = _build_node_map(parsed.components)
|
||||
tiers = _classify_node_tiers(node_map)
|
||||
ordered = _assign_columns(parsed.components, node_map)
|
||||
|
||||
if not ordered:
|
||||
d = schemdraw.Drawing(fontsize=11)
|
||||
d.add(elm.Label().label("(empty circuit)"))
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
d = schemdraw.Drawing(fontsize=11)
|
||||
|
||||
# Group by role for logical ordering
|
||||
sources = [c for c in parsed.components if c.prefix in ("V", "I")]
|
||||
passives = [c for c in parsed.components if c.prefix in ("R", "C", "L")]
|
||||
active = [c for c in parsed.components if c.prefix in ("D", "Q", "M")]
|
||||
other = [
|
||||
c for c in parsed.components
|
||||
if c.prefix not in ("V", "I", "R", "C", "L", "D", "Q", "M")
|
||||
]
|
||||
ordered = sources + passives + active + other
|
||||
|
||||
cols = min(4, max(2, len(ordered)))
|
||||
x_spacing = 8
|
||||
y_spacing = 5
|
||||
|
||||
for i, comp in enumerate(ordered):
|
||||
row = i // cols
|
||||
col = i % cols
|
||||
x = col * x_spacing
|
||||
y = -row * y_spacing
|
||||
# ── Phase 1: Place components vertically between node tiers ──
|
||||
placed_list: list[PlacedComponent] = []
|
||||
|
||||
for col, comp in enumerate(ordered):
|
||||
x = col * _GRID_X_SPACING
|
||||
elem = _get_element(comp, parsed.models)
|
||||
|
||||
# 3+ terminal devices (BJT, MOSFET) need special handling
|
||||
if comp.prefix in ("Q", "M") and len(comp.nodes) >= 3:
|
||||
placed = d.add(elem.at((x, y)).label(_component_label(comp)))
|
||||
_label_multiterminal(d, placed, comp)
|
||||
else:
|
||||
placed = d.add(
|
||||
elem.at((x, y)).right().label(_component_label(comp))
|
||||
# Multi-terminal device: center between top/bottom node tiers
|
||||
node_ys = [tiers.get(n, _GRID_MID_Y) for n in comp.nodes[:3]]
|
||||
center_y = (max(node_ys) + min(node_ys)) / 2
|
||||
placed_elem = d.add(
|
||||
elem.at((x, center_y)).label(_component_label(comp))
|
||||
)
|
||||
elif len(comp.nodes) >= 2:
|
||||
# 2-terminal: orient vertically between the two node tiers
|
||||
y0 = tiers.get(comp.nodes[0], _GRID_UPPER_Y)
|
||||
y1 = tiers.get(comp.nodes[1], _GRID_LOWER_Y)
|
||||
length = max(abs(y0 - y1), _GRID_MIN_COMP_LEN)
|
||||
|
||||
if y0 >= y1:
|
||||
# node[0] higher → go down so start=node[0], end=node[1]
|
||||
placed_elem = d.add(
|
||||
elem.at((x, y0)).down().length(length)
|
||||
.label(_component_label(comp), loc="right")
|
||||
)
|
||||
else:
|
||||
# node[0] lower → go up so start=node[0], end=node[1]
|
||||
placed_elem = d.add(
|
||||
elem.at((x, y0)).up().length(length)
|
||||
.label(_component_label(comp), loc="right")
|
||||
)
|
||||
else:
|
||||
# Fallback: single-node or unusual component
|
||||
placed_elem = d.add(
|
||||
elem.at((x, _GRID_MID_Y)).down().length(_GRID_MIN_COMP_LEN)
|
||||
.label(_component_label(comp), loc="right")
|
||||
)
|
||||
|
||||
positions = _get_terminal_positions(placed_elem, comp)
|
||||
placed_list.append(PlacedComponent(
|
||||
comp=comp, element=placed_elem, column=col,
|
||||
terminal_positions=positions,
|
||||
))
|
||||
|
||||
# ── Phase 2: Collect terminal positions grouped by node name ──
|
||||
node_positions: dict[str, list[tuple[float, float]]] = {}
|
||||
for pc in placed_list:
|
||||
for i, node in enumerate(pc.comp.nodes):
|
||||
if i in pc.terminal_positions:
|
||||
node_positions.setdefault(node, []).append(
|
||||
pc.terminal_positions[i]
|
||||
)
|
||||
|
||||
# ── Phase 3: Route wires by node type ────────────────────────
|
||||
for node, positions in node_positions.items():
|
||||
if len(positions) < 2:
|
||||
# Single connection: open dot with node label
|
||||
if positions and not _is_ground(node) and not _is_supply(node):
|
||||
px, py = positions[0]
|
||||
d.add(
|
||||
elm.Dot(open=True).at((px, py))
|
||||
.label(node, loc="right", fontsize=9)
|
||||
)
|
||||
continue
|
||||
|
||||
if _is_ground(node):
|
||||
_draw_ground_rail(d, positions)
|
||||
elif _is_supply(node):
|
||||
_, src_val = _find_supply_value(parsed.components, node)
|
||||
_draw_supply_rail(d, positions, node, src_val)
|
||||
elif len(positions) == 2:
|
||||
_draw_signal_wire(d, positions[0], positions[1])
|
||||
# Label at the midpoint of the wire
|
||||
mx = (positions[0][0] + positions[1][0]) / 2
|
||||
my = (positions[0][1] + positions[1][1]) / 2
|
||||
d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=9))
|
||||
else:
|
||||
# 3+ connections: star wiring with junction dot
|
||||
_draw_star_wires(d, positions)
|
||||
xs = sorted(p[0] for p in positions)
|
||||
ys = sorted(p[1] for p in positions)
|
||||
d.add(
|
||||
elm.Label().at((xs[len(xs) // 2], ys[len(ys) // 2]))
|
||||
.label(node, loc="right", fontsize=9)
|
||||
)
|
||||
_label_two_terminal(d, placed, comp)
|
||||
|
||||
return d.get_imagedata("svg").decode()
|
||||
|
||||
|
||||
@ -72,6 +72,15 @@ export function SpiceCell({ cell, isFirst, isLast }: SpiceCellProps) {
|
||||
[cell.id, cell.source, updateCellSource],
|
||||
);
|
||||
|
||||
// Auto-generate schematic on first render if none exists
|
||||
const hasAutoGenerated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (schematicSvg || hasAutoGenerated.current) return;
|
||||
if (!cell.source.trim() || cell.source.trim() === '* SPICE Netlist\n\nR1 in out 1k\nV1 in 0 DC 5\n\n.op\n.end') return;
|
||||
hasAutoGenerated.current = true;
|
||||
generateSchematic(cell.id);
|
||||
}, [cell.id, cell.source, schematicSvg, generateSchematic]);
|
||||
|
||||
// Debounced auto-redraw: regenerate schematic 800ms after source changes.
|
||||
// Track the source at last generation to avoid retriggering from our own SVG updates.
|
||||
const lastGeneratedSource = useRef<string | null>(null);
|
||||
|
||||
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 type { WaveformData } from '../../../lib/types';
|
||||
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
|
||||
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
|
||||
|
||||
// uPlot is a vanilla JS lib; import it and its CSS
|
||||
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) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [scopeMode, setScopeMode] = useState(() => {
|
||||
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
const updateWidth = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
@ -226,16 +256,43 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
|
||||
return () => observer.disconnect();
|
||||
}, [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 isEmpty =
|
||||
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
|
||||
|
||||
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 ? (
|
||||
<div className="text-slate-500 text-sm py-4 text-center">
|
||||
No waveform data to display.
|
||||
</div>
|
||||
) : scopeMode ? (
|
||||
<ScopeWaveformViewer
|
||||
waveform={waveform}
|
||||
width={width}
|
||||
onExitScope={toggleScope}
|
||||
/>
|
||||
) : waveform.is_complex ? (
|
||||
<ACPlot waveform={waveform} width={width} />
|
||||
) : (
|
||||
|
||||
@ -104,3 +104,83 @@ export const TRACE_COLORS = [
|
||||
export function traceColor(index: number): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
html {
|
||||
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