velxio/frontend/src/components/DynamicComponent.tsx

849 lines
36 KiB
TypeScript

/**
* Dynamic Component Renderer
*
* Generic component that renders any wokwi-element web component dynamically.
* Replaces individual React wrapper components (LED.tsx, Resistor.tsx, etc.)
*
* Features:
* - Creates web component from metadata
* - Syncs React props to web component properties
* - Extracts pinInfo from DOM for wire connections
* - Handles component lifecycle
*/
import React, { useRef, useEffect, useCallback } from 'react';
import type { ComponentMetadata } from '../types/component-metadata';
import {
useSimulatorStore,
getBoardBridge,
getBoardPinManager,
getBoardSimulator,
} from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore';
import { useEditorStore } from '../store/useEditorStore';
import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles';
import { PartSimulationRegistry } from '../simulation/parts';
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
import { isPiBoardKind } from '../types/board';
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
import {
createDefaultPinResolver,
createSpiceResolvedPinResolver,
configFromLogicFamily,
type PinResolver,
} from '../simulation/PinResolver';
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { traceDetailed } from '../simulation/PinTrace';
import { withPartPinOwnership, releasePartPins } from '../simulation/partPinOwnership';
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
// Side-effect imports: register every web component we'll create at runtime.
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
// the velxio-local elements (e.g. <velxio-capacitor-electrolytic>,
// <velxio-instr-voltmeter>) that don't exist upstream.
import '@wokwi/elements';
import '../velxio-elements';
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
// The wire-graph walk that answers "which board pin owns this component pin"
// lives in simulation/PinTrace so the store can ask the SAME question when it
// pre-registers backend sensors. Re-exported here: it was this module's API.
export { traceDetailed };
interface DynamicComponentProps {
id: string;
metadata: ComponentMetadata;
properties: Record<string, any>;
x?: number;
y?: number;
isSelected?: boolean;
isHovered?: boolean;
onMouseDown?: (e: React.MouseEvent) => void;
/** Right click: the canvas opens the properties + pins dialog here. */
onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
onPinInfoReady?: (pinInfo: any[]) => void;
}
export const DynamicComponent: React.FC<DynamicComponentProps> = ({
id,
metadata,
properties,
x = 0,
y = 0,
isSelected = false,
isHovered = false,
onMouseDown,
onContextMenu,
onDoubleClick,
onMouseEnter,
onMouseLeave,
onPinInfoReady,
}) => {
const elementRef = useRef<HTMLElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(false);
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
const running = useSimulatorStore((s) => s.running);
const simulator = useSimulatorStore((s) => s.simulator);
// Board-less SPICE circuits (digital / analog gallery) have no MCU to
// run, so `running` is always false — but interactive parts like
// slide-switches and pushbuttons should still show a pointer cursor
// and let the user click them. We treat board-less + un-paused as
// "interactive" so the cursor + dialog gating mirror the MCU mode.
const boardCount = useSimulatorStore((s) => s.boards.length);
const electricalPaused = useElectricalStore((s) => s.paused);
const interactionRunning = running || (boardCount === 0 && !electricalPaused);
// hexEpoch increments each time a new hex is loaded, triggering a fresh
// attachEvents call (and re-registration of I2C devices on the new bus).
// We intentionally do NOT depend on `running` so that I2C displays and
// other protocol parts (SSD1306, DS1307 …) are NOT torn down and
// re-created on every stop/play cycle — which previously caused the
// display to flash blank and lose its frame buffer.
const hexEpoch = useSimulatorStore((s) => s.hexEpoch);
// Runtime burnout (P4): destroyed parts render charred + a smoke badge.
const isBurnt = useSimulatorStore((s) => s.burntComponents.has(id));
// Track wires connected to this component so attachEvents re-runs when
// wires are added or removed (e.g. disconnecting an LED cathode from GND).
const wireFingerprint = useSimulatorStore((s) => {
const myWires = s.wires.filter((w) => w.start.componentId === id || w.end.componentId === id);
return myWires.map((w) => w.id).join(',');
});
// Check if component is interactive (has simulation logic with attachEvents)
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
const isInteractive = logic?.attachEvents !== undefined;
/**
* Sync React properties to Web Component.
*
* Values arriving as strings (agent set_component_property, the text
* inputs in the property dialog) are coerced to the type of the
* metadata DEFAULT for that key. Without this, `el.digits = '4'`
* (string) silently breaks wokwi elements that strict-match
* (`switch (this.digits) { case 4: ... }` -> falls back to the 1-digit
* pinout), and `'false'` stays truthy for boolean props like colon.
*/
// Last property values actually applied to the element. Used to detect
// real changes below — the store hands us a NEW properties object on every
// update even when the values are identical, and the change-notification
// path (dispatch 'input' → part sim → emitPropertyChange → store) would
// otherwise echo forever.
const appliedPropsRef = useRef<Record<string, unknown>>({});
useEffect(() => {
if (!elementRef.current) return;
let changed = false;
const numericChanges: Record<string, number> = {};
Object.entries(properties).forEach(([key, value]) => {
try {
// Display framebuffers round-trip through saved projects as strings
// ('' — the generated metadata lists imageData as a text prop).
// Assigning that string clobbers the element's live ImageData and
// crashes wokwi-elements' firstUpdated (putImageData TypeError).
if (key === 'imageData' && !(value instanceof ImageData)) return;
let coerced: any = value;
if (typeof value === 'string') {
const def = metadata.defaultValues?.[key];
if (typeof def === 'number' && value.trim() !== '' && !Number.isNaN(Number(value))) {
coerced = Number(value);
} else if (typeof def === 'boolean') {
coerced = value === 'true' || value === '1';
}
}
(elementRef.current as any)[key] = coerced;
if (appliedPropsRef.current[key] !== coerced) {
appliedPropsRef.current[key] = coerced;
changed = true;
if (typeof coerced === 'number') numericChanges[key] = coerced;
}
} catch (error) {
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
}
});
// A bare property assignment is invisible to the part simulators: they
// bind to DOM events ('input'/'change' — how the element's own knob
// notifies) or to the SensorUpdateRegistry (how the sensor panel
// notifies). Programmatic writes — the agent's set_component_property,
// values restored from a saved project, the property dialog — used to
// reach the ELEMENT but never the running simulation, so a pot "set" to
// 800 kept reading ADC 0 until a human touched its knob. Notify both
// channels, only on real value changes (see appliedPropsRef above).
if (changed) {
try {
elementRef.current.dispatchEvent(new Event('input'));
elementRef.current.dispatchEvent(new Event('change'));
if (Object.keys(numericChanges).length) {
dispatchSensorUpdate(id, numericChanges);
}
} catch (error) {
console.warn(`Failed to notify simulation of ${metadata.tagName} change:`, error);
}
}
}, [properties, metadata.tagName, id]);
/**
* Property changes that swap the element's pin SET (7segment digits,
* LED flip, display pins edge) re-render asynchronously and announce
* themselves with a 'pininfo-change' event. Re-derive the breadboard
* seating then — reseating synchronously on the property write would
* read the STALE pinout and seat ghost pins.
*/
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onPinInfoChange = () => {
try {
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
} catch {
// headless / tests
}
};
el.addEventListener('pininfo-change', onPinInfoChange);
return () => el.removeEventListener('pininfo-change', onPinInfoChange);
}, [id, metadata.tagName]);
/**
* Reseat once the element's geometry first becomes measurable.
*
* A part can land in the store at its FINAL position before its element
* mounts — the agent streams add_component + a seating move in one batch,
* and `updateComponent`'s reseat then finds no DOM (computeSeating null)
* and keeps the (empty) seating. Nothing re-derived it afterwards: the
* seat-correction skips when the position needs no nudge, and
* 'pininfo-change' only fires on pin-SET swaps, not on plain init. So the
* part had no bb wires until the user dragged it or reloaded — a clock
* started by the agent in that window ran against a dead display, while
* reload+run worked (bb wires are persisted). Deriving the seating at
* mount closes that hole for every path (agent, load, undo).
*/
useEffect(() => {
const tryReseat = () => {
try {
const pinInfo = (elementRef.current as any)?.pinInfo;
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
return true;
}
} catch {
// element not ready yet / headless tests
}
return false;
};
// Wires resolve through the same pinInfo, and the canvas' load-settle
// timers (100/300/500ms) can ALL fire before this element is findable —
// an overlay-defined custom element upgrades when its (large) chunk
// lands, and on the example route the wires themselves are stored after
// further awaits. So: the first time THIS part's pinInfo is actually
// readable from the DOM, re-derive every wire endpoint. Measured on
// staging: without this, all four wires to the part sat on its corner
// until the user nudged something.
const tryWires = () => {
try {
const el = document.getElementById(id) as (HTMLElement & { pinInfo?: unknown[] }) | null;
if (el && Array.isArray(el.pinInfo) && el.pinInfo.length > 0) {
useSimulatorStore.getState().recalculateAllWirePositions();
return true;
}
} catch {
// headless tests
}
return false;
};
const reseated = tryReseat();
const wired = tryWires();
if (reseated && wired) return;
// Poll until both settle. 10s, not 2s: the overlay chunk that defines
// the element can take that long on a slow connection, and giving up
// early is exactly the corner-wire bug again.
let done = { reseat: reseated, wires: wired };
const interval = setInterval(() => {
if (!done.reseat) done.reseat = tryReseat();
if (!done.wires) done.wires = tryWires();
if (done.reseat && done.wires) clearInterval(interval);
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 10000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [id, metadata.tagName]);
/**
* Extract pinInfo from web component after it initializes
*/
useEffect(() => {
if (!elementRef.current || !onPinInfoReady) return;
// Wait for web component to fully initialize
const checkPinInfo = () => {
try {
const pinInfo = (elementRef.current as any)?.pinInfo;
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
onPinInfoReady(pinInfo);
return true;
}
} catch {
// Element not ready yet
}
return false;
};
// Try immediately
if (checkPinInfo()) return;
// Otherwise poll every 100ms for up to 2 seconds
const interval = setInterval(() => {
if (checkPinInfo()) {
clearInterval(interval);
}
}, 100);
const timeout = setTimeout(() => {
clearInterval(interval);
}, 2000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [onPinInfoReady]);
/**
* Handle mouse events
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!onMouseDown) return;
// Don't swallow the pointerdown for wokwi components that own their
// own pointer interaction (rotary knobs, pushbuttons, slide-switches,
// joysticks, keypads, encoders). For those the wokwi element binds
// pointerdown/move/up on its shadow-DOM SVG; if we call
// stopPropagation() in the capture phase here the internal logic
// never sees the event and the knob can't rotate, the button never
// reports pressed, etc.
//
// EVERY OTHER component (sensors, displays, LEDs, resistors, even
// ones with attachEvents for the sensor-update / SPICE-prop bridge)
// expects clicks to bubble up to the canvas → open the property
// dialog or grab for drag-to-rearrange. The previous "swallow only
// when isInteractive" heuristic was too broad: it included DHT22,
// HC-SR04, NTC, photoresistor, LED, etc. — all of which have
// attachEvents but no internal pointer handler, so clicks on them
// SHOULD bubble. With the broad guard, those dialogs never opened.
//
// The whitelist below is tight on purpose: only add a tag name when
// the wokwi element actually has its own pointerdown handler that
// the user needs to reach. If a new interactive part is added,
// append its tag here.
const target = e.target as HTMLElement;
const tag = target.tagName?.toLowerCase() ?? '';
const ownsPointer =
interactionRunning &&
(tag === 'wokwi-pushbutton' ||
tag === 'wokwi-pushbutton-6mm' ||
tag === 'wokwi-potentiometer' ||
tag === 'wokwi-slide-potentiometer' ||
tag === 'wokwi-slide-switch' ||
tag === 'wokwi-dip-switch-8' ||
tag === 'wokwi-analog-joystick' ||
tag === 'wokwi-ky-040' ||
tag === 'wokwi-membrane-keypad' ||
tag === 'wokwi-rotary-dialer' ||
// Rule-6a escape hatch: a (possibly private-overlay) element whose
// surface IS the interaction — a touch screen — declares it via a
// property instead of this list growing pro tag names. While the
// sim runs, touching it must touch, not drag: the Round Display's
// glass was painting the green dot AND dragging the shield around.
(target as { ownsPointer?: boolean }).ownsPointer === true);
if (ownsPointer) {
// A declared touch SCREEN (ownsPointer property, not the wokwi tag
// list): its model listens on POINTER events — a separate stream —
// so stopping THIS mousedown costs it nothing, and it must be
// stopped: left-drag that reaches the canvas background pans the
// whole world under the finger mid-swipe. Wokwi knobs keep the
// legacy pass-through, their internal handlers may bind this very
// mouse event.
if ((target as { ownsPointer?: boolean }).ownsPointer === true) e.stopPropagation();
// Let the component own this pointerdown.
return;
}
e.stopPropagation();
onMouseDown(e);
},
[onMouseDown, interactionRunning],
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (onDoubleClick) {
e.stopPropagation();
onDoubleClick(e);
}
},
[onDoubleClick],
);
/**
* Mount web component (only once)
*/
useEffect(() => {
if (!containerRef.current) return;
// Prevent double-mount in React StrictMode
if (mountedRef.current) {
return;
}
const element = document.createElement(metadata.tagName);
element.id = id;
// Set initial properties
Object.entries(properties).forEach(([key, value]) => {
try {
// Same guard as the property-sync effect above: the persisted
// imageData string must never replace the element's live ImageData
// (it crashes wokwi-elements' firstUpdated on mount).
if (key === 'imageData' && !(value instanceof ImageData)) return;
(element as any)[key] = value;
} catch (error) {
console.warn(`Failed to set initial property ${key}:`, error);
}
});
containerRef.current.appendChild(element);
elementRef.current = element;
mountedRef.current = true;
return () => {
if (containerRef.current && element.parentNode === containerRef.current) {
containerRef.current.removeChild(element);
}
elementRef.current = null;
mountedRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metadata.tagName, id]); // Only re-create if tagName or id changes
/**
* Attach component-specific DOM events (like button presses)
*/
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const onButtonPress = (e: Event) => handleComponentEvent(id, 'button-press', e);
const onButtonRelease = (e: Event) => handleComponentEvent(id, 'button-release', e);
el.addEventListener('button-press', onButtonPress);
el.addEventListener('button-release', onButtonRelease);
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
let cleanupSimulationEvents: (() => void) | undefined;
if (logic && logic.attachEvents) {
// Board-less circuits (analog/digital SPICE examples) have no MCU
// simulator, but input parts (switches, buttons, DIP switches) still
// need their `change`/`button-press` events to fire `emitPropertyChange`
// so the SPICE solver re-runs. Every part already guards its
// `simulator.setPinState` / `pinManager.onPinChange` calls behind a
// null pin lookup (`getArduinoPin` returns null when there's no board),
// so the stub below is enough — it satisfies the type signature without
// doing anything when called.
// A QEMU-Linux board (Raspberry Pi family, UNIHIKER) has no MCU
// simulator: the guest IS the CPU. An input part still calls
// `simulator.setPinState(pin, level)` to report a button press or a
// PIR trip, and that call used to land on the legacy AVR instance and
// vanish — clicking the sensor did nothing at all. Route it to the
// bridge of the board this component is actually wired to: `gpio_in`
// for the guest, the canvas-fed `pin<N>` value the browser engine's
// shims read, and the PinManager so wires and SPICE see the edge.
const { piBoardId, wiredBoardId } = (() => {
const st = useSimulatorStore.getState();
const ownPins = new Set<string>();
for (const w of st.wires) {
if (w.start.componentId === id) ownPins.add(w.start.pinName);
if (w.end.componentId === id) ownPins.add(w.end.pinName);
}
let anyBoardId: string | null = null;
for (const pinName of ownPins) {
const { boardId } = traceDetailed(st, id, pinName, 0);
const board = boardId ? st.boards.find((b) => b.id === boardId) : undefined;
if (!board) continue;
if (anyBoardId === null) anyBoardId = board.id;
if (isPiBoardKind(board.boardKind)) return { piBoardId: board.id, wiredBoardId: board.id };
}
return { piBoardId: null, wiredBoardId: anyBoardId };
})();
const piSimulator = piBoardId
? ({
setPinState: (pin: number, state: boolean) => {
getBoardBridge(piBoardId)?.sendPinEvent(pin, state);
getBoardBridge(piBoardId)?.setSensorState({ [`pin${pin}`]: state ? 1 : 0 });
getBoardPinManager(piBoardId)?.triggerPinChange(pin, state, 'external');
},
isRunning: () =>
!!useSimulatorStore.getState().boards.find((b) => b.id === piBoardId)?.running,
pinManager: getBoardPinManager(piBoardId),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
: null;
// Route the part to the simulator of the board it is actually WIRED
// to. The legacy store `simulator` is the shared AVR instance: handing
// it to a part on an ESP32 board silently voided every direct analog
// injection — setAdcVoltage() hit the AVR branch, GPIO 32-39 fell
// outside its 14-19 window, and the part (analog-joystick was the
// reported one) read 0 forever while pots survived only via the SPICE
// solve. getBoardSimulator() returns the per-board shim (ESP32 bridge
// shim, RP2040, AVR) that partUtils' dispatch understands.
const wiredSimulator = wiredBoardId ? getBoardSimulator(wiredBoardId) ?? null : null;
const stubSimulator =
piSimulator ??
wiredSimulator ??
simulator ??
({
setPinState: () => {},
isRunning: () => false,
// Board-less circuits have no MCU simulator, but a custom chip still
// needs a real PinManager so its digital pin writes/reads reach the
// components wired to it (LEDs, buttons, other chips). Hand it the
// shared flat PinManager that SimulatorCanvas subscribes LEDs to, so
// both sides talk on the same numeric/synthetic pin ids. Falls back
// to a no-op only if even that isn't ready yet.
pinManager:
(useSimulatorStore.getState().pinManager as any) ?? {
onPinChange: () => () => {},
triggerPinChange: () => {},
},
} as any);
// Helper to find Arduino pin connected to a component pin.
// Traces through electrically-transparent passive components so that a
// circuit like LED-cathode → resistor → GND returns -1 (GND) instead
// of null. Delegates to the module-level `traceDetailed`.
//
// Two call shapes are supported because this same function is passed
// BOTH to PartSimulationRegistry handlers (which call it as
// `getArduinoPin(componentPinName)`) AND to `createDefaultPinResolver`
// as a `PinTracer` (which calls it as `tracePin(componentId,
// componentPinName)`). When the second arg is present we treat the
// first as a componentId override; otherwise we use the closure-
// captured component id. The previous single-arg signature silently
// matched the PinTracer 2-arg call as `(componentId, undefined)` —
// traceDetailed then looked up a pin literally named "rgb-led-1" on
// component "rgb-led-1", got null, and the PinResolver reported
// FLOATING forever (the canonical "wokwi-rgb-led never lights up
// even though SPICE is driving R/G/B" symptom).
const getArduinoPin = (
componentIdOrPin: string,
maybePinName?: string,
): number | null => {
const state = useSimulatorStore.getState();
const componentId = maybePinName !== undefined ? componentIdOrPin : id;
const componentPinName =
maybePinName !== undefined ? maybePinName : componentIdOrPin;
return traceDetailed(state, componentId, componentPinName, 0).arduinoPin;
};
// PinResolver factory — Phase 0 of the mixed-mode simulator project
// (see project/sim-mixedmode/ in the velxio-prod repo). For now it
// wraps getArduinoPin + pinManager.onPinChange — zero behavioral
// change vs the legacy path. Phase 1+ will swap in a SPICE-resolved
// implementation that watches node voltages and threshold-converts
// to logic states.
const simState = useSimulatorStore.getState();
const ownerBoard =
simState.boards.find((b) => b.id === simState.activeBoardId) ?? null;
const ownerBoardVcc =
(ownerBoard && BOARD_PIN_GROUPS[ownerBoard.boardKind as keyof typeof BOARD_PIN_GROUPS]?.vcc) ?? 5;
const getPinResolver = (componentPinName: string): PinResolver | null => {
const state = useSimulatorStore.getState();
const pinManager = (stubSimulator as {
pinManager?: {
onPinChange?: (pin: number, cb: (pin: number, state: boolean) => void) => () => void;
getPinState?: (pin: number) => boolean | null;
};
}).pinManager;
// Phase 1b: detect whether the path between this component pin and
// an Arduino pin passes through any active device (BJT, MOSFET,
// op-amp, diode, regulator). If yes → use the SPICE-resolved
// resolver flavor so the digital state is derived from real node
// voltages (handles transistor inversion, op-amp gain, diode
// forward-drop, etc.). If no → use the legacy digital fast-path
// (zero SPICE cost, identical to Phase 0 behavior).
const detailed = traceDetailed(state, id, componentPinName, 0);
if (detailed.crossedActiveDevice) {
const scheduler = getMixedModeScheduler();
// Phase 3: threshold model from the OWNER BOARD's logic family
// (e.g. AVR_HC for Uno, LVCMOS33 for ESP32). Includes Schmitt
// hysteresis when the family declares it. Phase 3 continued
// will let individual components override via a `logicFamily`
// field in components-metadata.json so e.g. a 74HC14 input
// gets Schmitt behavior even when driven from an AVR.
const family = ownerBoard
? getBoardLogicFamily(ownerBoard.boardKind)
: { vcc: ownerBoardVcc, vil: ownerBoardVcc / 2, vih: ownerBoardVcc / 2 };
return createSpiceResolvedPinResolver(
id,
componentPinName,
scheduler,
configFromLogicFamily(family),
);
}
return createDefaultPinResolver(
id,
componentPinName,
{
components: state.components,
boards: state.boards,
wires: state.wires,
ownerBoard,
ownerBoardVcc,
subscribeArduinoPin: (pin, cb) => {
if (!pinManager?.onPinChange) return () => {};
return pinManager.onPinChange(pin, cb);
},
readArduinoPin: (pin) => {
if (!pinManager?.getPinState) return null;
try {
return pinManager.getPinState(pin);
} catch {
return null;
}
},
},
getArduinoPin,
);
};
// microSD auto-copy (free, Wokwi model): bake the project's workspace
// files into a FAT16 image the card serves over SD-over-SPI. Paid uploads
// (the "SD Card" panel) will merge into this list in a later phase.
if (metadata.id === 'microsd-card') {
try {
const uploaded = decodeSdFiles(properties.sdFiles); // paid uploads (if any)
(el as unknown as { sdImageData?: Uint8Array }).sdImageData =
buildProjectSdImage(useEditorStore.getState().files, uploaded);
} catch (e) {
console.warn('[microsd] SD image build failed:', e);
}
}
// Every pin this part drives itself becomes ITS pin for as long as it is
// attached, so the SPICE-threshold input path leaves that line alone (see
// simulation/partPinOwnership). Claims are implicit — a part that never
// drives a pin never takes one — and released in the cleanup below, so a
// rewire or an unmount hands the pin straight back to the circuit.
releasePartPins(id);
const ownedSimulator = withPartPinOwnership(
stubSimulator,
piBoardId ?? wiredBoardId ?? useSimulatorStore.getState().activeBoardId ?? null,
id,
metadata.id,
);
cleanupSimulationEvents = logic.attachEvents(
el,
ownedSimulator,
getArduinoPin,
id,
getPinResolver,
);
}
return () => {
if (cleanupSimulationEvents) cleanupSimulationEvents();
releasePartPins(id);
el.removeEventListener('button-press', onButtonPress);
el.removeEventListener('button-release', onButtonRelease);
};
}, [id, handleComponentEvent, metadata.id, simulator, hexEpoch, wireFingerprint]);
// The wrapper uses `onMouseDownCapture` (not `onMouseDown`) so it sees
// the mousedown BEFORE the inner wokwi-element. Interactive wokwi parts
// (pushbutton, slide-switch, potentiometer …) call stopPropagation in
// their own bubble-phase handlers, which used to prevent any drag from
// starting once the simulator was running. Capture phase fires first
// and lets the canvas's drag-threshold logic distinguish click vs drag
// at mouseup time — so the user can rearrange interactive components
// while simulation is live.
return (
<div
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}${
isSelected ? ' velxio-ants' : ''
}`}
style={{
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
// The selection outline itself is the .velxio-ants pseudo-element
// (a static dashed border cannot be animated). The transparent
// border stays so selecting does not shift the body by 2px.
border: '2px solid transparent',
borderRadius: '4px',
padding: '4px',
userSelect: 'none',
// Drag-to-front (zRaise) beats the static layers: a part dragged onto
// a board — or a board dragged onto a part — the last one dragged
// paints on top. Untouched parts keep the classic selected/idle z.
// Local order inside .component-interactive-group only. The
// drag-to-front rank is applied on that GROUP (SimulatorCanvas) —
// a z set here is clamped by the group's stacking context and could
// never lift the part above a dragged board.
zIndex: isSelected ? 5 : 1,
pointerEvents: 'auto',
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
onMouseDownCapture={handleMouseDown}
// Capture phase: interactive parts (pushbutton, switch, pot) stop
// propagation in their own handlers, which would otherwise swallow the
// right click before the canvas ever saw it.
onContextMenuCapture={onContextMenu}
onTouchStartCapture={(e) => {
// Mobile mirror of the ownsPointer guard: while the sim runs, a
// finger on a declared touch screen is INPUT for the screen (its
// pointer handlers still fire), never a canvas pan/drag gesture.
const t = e.target as { ownsPointer?: boolean };
if (interactionRunning && t.ownsPointer === true) e.stopPropagation();
}}
onDoubleClick={handleDoubleClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-component-id={id}
data-component-type={metadata.id}
>
{/* Container for web component */}
<div ref={containerRef} className="web-component-container" />
{/* Runtime-burnout smoke badge (P4) */}
{isBurnt && (
<div
className="velxio-burnt-smoke"
aria-hidden="true"
style={{ position: 'absolute', top: '-7px', right: '-7px', pointerEvents: 'none', zIndex: 6 }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="14" r="5" fill="#6b7280" opacity="0.85" />
<circle cx="14" cy="11" r="6" fill="#9ca3af" opacity="0.85" />
<circle cx="17" cy="16" r="4" fill="#4b5563" opacity="0.85" />
<circle cx="11" cy="8" r="3.5" fill="#9ca3af" opacity="0.7" />
</svg>
</div>
)}
{/* Component label — revealed on hover/selection only.
A dense board (e.g. 8 vertical resistors at 19 px pitch) turned into
a wall of overlapping "Resistor 220 Ω" text that hid the breadboard
holes and the parts themselves. Hidden with OPACITY, never
`display`/`position`: pinPositionCalculator derives the rotation
pivot from `wrapper.offsetHeight`, so taking the label out of flow
would move every rotated component's pins. */}
<div
className="component-label"
style={{
fontSize: '11px',
textAlign: 'center',
marginTop: '4px',
color: '#666',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
opacity: isHovered || isSelected ? 1 : 0,
transition: 'opacity 120ms ease-out',
}}
>
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
{isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && (
<span
style={{
fontSize: '9px',
padding: '1px 5px',
borderRadius: '3px',
backgroundColor: '#2d2d2d',
color: '#ddd',
border: '1px solid #555',
borderBottomWidth: '2px',
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
fontWeight: 600,
lineHeight: '1.3',
whiteSpace: 'nowrap',
}}
>
{formatKeyLabel(properties.key)}
</span>
)}
{properties.protocol && (
<span
style={{
fontSize: '9px',
padding: '1px 4px',
borderRadius: '3px',
backgroundColor: properties.protocol === 'spi' ? '#e67e22' : '#3498db',
color: '#fff',
fontWeight: 600,
textTransform: 'uppercase',
lineHeight: '1.2',
}}
>
{String(properties.protocol)}
</span>
)}
</div>
</div>
);
};
/**
* Helper function to create a component instance from metadata
*/
export function createComponentFromMetadata(
metadata: ComponentMetadata,
x: number,
y: number,
): {
id: string;
metadataId: string;
x: number;
y: number;
properties: Record<string, any>;
} {
// Underscore separators (not '-') so the resulting id is safe to embed
// in SPICE component / source names. ngspice's WASM build truncates
// vector keys at '-', which broke branch-current lookups for any LED /
// ammeter wired up by the user (visible symptom: correct node voltage,
// dark LED). Also strip '-' from metadata.id (e.g. 'led-bar-graph') so
// the prefix doesn't reintroduce a hyphen.
const safePrefix = metadata.id.replace(/-/g, '_');
const properties: Record<string, any> = { ...metadata.defaultValues };
// Resistors default to vertical: they read better, take less horizontal
// space, and drop straight into breadboard columns (their pin span
// bridges the center trench). Covers 'resistor' and every preconfigured
// 'resistor-<value>' variant; anything with an explicit rotation in its
// metadata defaults keeps it.
if (metadata.id.startsWith('resistor') && properties.rotation === undefined) {
properties.rotation = 90;
}
return {
id: `${safePrefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
metadataId: metadata.id,
x,
y,
properties,
};
}