/** * Floating SVG overlay that renders voltage labels at wire midpoints. * SPICE is always active; this overlay is visible whenever a solve has * landed. Reads voltages from `useElectricalStore.nodeVoltages` (updated * by the scheduler) and maps wires to nets via `buildWireNetMap`. * * For nets with AC content (detected via `.tran` `timeWaveforms`) labels * show `~` prefixed with a tilde to mark them as time-varying. A * summary pill in the top-left advertises DC vs AC analysis mode. * * This is a read-only, zero-interactivity layer — it sits ABOVE the wire * layer but below the component layer so labels remain legible without * blocking clicks. * * Labels are HOVER-GATED. Showing every net at once buried dense boards * (a 4-digit 7-segment clock draws ~40 wires, so ~40 `0uV` pills covered * the breadboard). A label is drawn only when the user hovers its wire, * or hovers a component/board that wire lands on — so pointing at a part * reveals every voltage around it at once. The summary pill always shows. */ import { useMemo } from 'react'; import { useElectricalStore } from '../../store/useElectricalStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { buildWireNetMap } from '../../simulation/spice/NetlistBuilder'; import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups'; import { rms, isAC } from '../../simulation/spice/waveformStats'; function formatV(v: number): string { const abs = Math.abs(v); if (abs < 1e-3) return `${(v * 1e6).toFixed(0)}uV`; if (abs < 1) return `${(v * 1e3).toFixed(1)}mV`; if (abs < 100) return `${v.toFixed(2)}V`; return `${v.toFixed(1)}V`; } interface ElectricalOverlayProps { /** Wire currently under the cursor (canvas-level hit test). */ hoveredWireId?: string | null; /** Component under the cursor — reveals every wire touching it. */ hoveredComponentId?: string | null; /** Board under the cursor — same, for wires landing on board pins. */ hoveredBoardId?: string | null; } export function ElectricalOverlay({ hoveredWireId = null, hoveredComponentId = null, hoveredBoardId = null, }: ElectricalOverlayProps = {}) { const nodeVoltages = useElectricalStore((s) => s.nodeVoltages); const converged = useElectricalStore((s) => s.converged); const error = useElectricalStore((s) => s.error); const solveMs = useElectricalStore((s) => s.lastSolveMs); const analysisMode = useElectricalStore((s) => s.analysisMode); const timeWaveforms = useElectricalStore((s) => s.timeWaveforms); const wires = useSimulatorStore((s) => s.wires); const components = useSimulatorStore((s) => s.components); const boards = useSimulatorStore((s) => s.boards); const labels = useMemo(() => { if (Object.keys(nodeVoltages).length === 0) return []; const boardsForSpice = boards.map((b) => { const pg = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default; return { id: b.id, vcc: pg.vcc, groundPinNames: pg.gnd, vccPinNames: pg.vcc_pins, auxPinNames: pg.aux?.pins, auxVolts: pg.aux?.volts, pins: {}, }; }); const compsForSpice = components.map((c) => ({ id: c.id, metadataId: c.metadataId, properties: c.properties ?? {}, })); const wiresForSpice = wires.map((w) => ({ id: w.id, start: { componentId: w.start.componentId, pinName: w.start.pinName }, end: { componentId: w.end.componentId, pinName: w.end.pinName }, })); const wireNetMap = buildWireNetMap({ components: compsForSpice, wires: wiresForSpice, boards: boardsForSpice, }); return wires .map((w) => { const mx = (w.start.x + w.end.x) / 2; const my = (w.start.y + w.end.y) / 2; const netName = wireNetMap.get(w.id); const samples = netName ? timeWaveforms?.nodes.get(netName) : undefined; const ac = samples && samples.length > 0 && isAC(samples); const displayV = ac ? rms(samples!) : netName ? nodeVoltages[netName] : undefined; return { id: w.id, x: mx, y: my, v: displayV, netName, ac, // Endpoint owners, so hovering a part can reveal its wires. a: w.start.componentId, b: w.end.componentId, }; }) .filter((l) => l.v !== undefined && l.netName !== '0'); }, [wires, components, boards, nodeVoltages, timeWaveforms]); // Hover gate kept in its own memo: the netlist rebuild above is expensive // and must not re-run every time the cursor moves. const visibleLabels = useMemo(() => { if (!hoveredWireId && !hoveredComponentId && !hoveredBoardId) return []; return labels.filter( (l) => l.id === hoveredWireId || (hoveredComponentId !== null && (l.a === hoveredComponentId || l.b === hoveredComponentId)) || (hoveredBoardId !== null && (l.a === hoveredBoardId || l.b === hoveredBoardId)), ); }, [labels, hoveredWireId, hoveredComponentId, hoveredBoardId]); const modeBadge = analysisMode === 'tran' ? 'AC' : 'DC'; const badgeColor = analysisMode === 'tran' ? '#4dd0e1' : '#ffa500'; const summaryLines: string[] = []; if (error) summaryLines.push(`Warning: ${error}`); else if (!converged) summaryLines.push('Warning: did not converge'); else { const n = Object.keys(nodeVoltages).length; summaryLines.push(`${n} nets | ${solveMs.toFixed(0)} ms`); } return ( {/* Summary pill with AC/DC badge */} {modeBadge} SPICE {summaryLines.join(' ')} {/* Per-wire voltage labels — only for what the cursor is on */} {visibleLabels.map((l) => ( {l.ac ? `~${formatV(l.v!)}` : formatV(l.v!)} ))} ); }