velxio/frontend/src/components/DynamicComponent.tsx

1000 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
} 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 { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
import { isBoardSeated } from '../utils/socketSnap';
import { isPiBoardKind } from '../types/board';
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
import {
createDefaultPinResolver,
createSpiceResolvedPinResolver,
configFromLogicFamily,
isActiveDevice,
type PinResolver,
} from '../simulation/PinResolver';
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
import { resolveChipNetKey } from '../simulation/customChips/chipNets';
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
import { breadboardGroupKey } from '../utils/breadboardNets';
// 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)
// Map metadataId → [pinA, pinB] for 2-terminal passives.
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
// (and vice-versa).
//
// NOTE: diodes / transistors / op-amps are NOT traced through as passives —
// they have polarity / Vf / non-linear behaviour that the digital layer
// cannot interpret as "same pin". BJTs are an explicit shortcut for the
// canonical "Arduino digital pin controls a load via transistor" pattern so
// 7-segment multiplex circuits with BJT digit drivers still resolve.
const PASSIVE_PIN_PAIRS_BASE: Record<string, [string, string]> = {
resistor: ['1', '2'],
'resistor-us': ['1', '2'],
capacitor: ['1', '2'],
'capacitor-electrolytic': ['+', ''],
inductor: ['1', '2'],
'analog-resistor': ['A', 'B'],
'analog-capacitor': ['A', 'B'],
'analog-inductor': ['A', 'B'],
'bjt-2n2222': ['C', 'B'],
'bjt-bc547': ['C', 'B'],
'bjt-2n3055': ['C', 'B'],
'bjt-2n3906': ['C', 'B'],
'bjt-bc557': ['C', 'B'],
};
// Preset variants of the generic passives share their parent's tag and pin
// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts.
const PRESET_TO_BASE: Record<string, string> = {
'resistor-220': 'resistor',
'resistor-330': 'resistor',
'resistor-470': 'resistor',
'resistor-1k': 'resistor',
'resistor-2k2': 'resistor',
'resistor-4k7': 'resistor',
'resistor-10k': 'resistor',
'resistor-22k': 'resistor',
'resistor-47k': 'resistor',
'resistor-100k': 'resistor',
'resistor-1m': 'resistor',
'cap-10p': 'capacitor',
'cap-22p': 'capacitor',
'cap-100p': 'capacitor',
'cap-1n': 'capacitor',
'cap-10n': 'capacitor',
'cap-100n': 'capacitor',
'cap-1u': 'capacitor',
'cap-elec-1u': 'capacitor-electrolytic',
'cap-elec-10u': 'capacitor-electrolytic',
'cap-elec-47u': 'capacitor-electrolytic',
'cap-elec-100u': 'capacitor-electrolytic',
'cap-elec-470u': 'capacitor-electrolytic',
'cap-elec-1000u': 'capacitor-electrolytic',
'ind-100u': 'inductor',
'ind-1m': 'inductor',
'ind-10m': 'inductor',
};
const PASSIVE_PIN_PAIRS: Record<string, [string, string]> = {
...PASSIVE_PIN_PAIRS_BASE,
};
for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base];
}
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// Custom-chip output pins get stable synthetic pin numbers from
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
// through passive components to reach a board pin. Returns the arduino pin
// plus a `crossedActiveDevice` flag so the resolver factory can decide
// between digital fast-path and SPICE-resolved per-pin.
//
// A real board pin always wins (digital GPIO semantics are unchanged). Only
// when NO board pin is reachable do we fall back to a custom-chip pin on the
// net — either a neighbour chip pin, or (when the trace itself started at a
// chip pin) the starting chip pin — resolving it to its synthetic number.
//
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
// can call it too — the previous nested-scope version caused a runtime
// ReferenceError "traceDetailed is not defined" on the simulator page.
/**
* Resolve a component pad through a SEATED board rather than a wire.
*
* Sockets are a real electrical connection with nothing to draw: the board's
* pads sit on the component's pads. `boardSocket` (read off the element, the
* rule-6a way) says the component is a socket; isBoardSeated says a board is
* actually in it; and the shared pad NAME is the contract that makes the two
* grids one net — which is exactly why a socket's pinInfo uses the board's own
* names. Returns null for anything that is not a seated socket pad.
*/
function traceThroughSocket(
state: TraceState,
componentId: string,
pinName: string,
): { pin: number; boardId: string } | null {
const el = document.getElementById(componentId) as
| (HTMLElement & { boardSocket?: { anchorPin: string; accepts: string[] } })
| null;
const sock = el?.boardSocket;
if (!sock || !Array.isArray(sock.accepts)) return null;
for (const b of state.boards) {
if (!sock.accepts.some((prefix) => b.boardKind.startsWith(prefix))) continue;
if (!isBoardSeated(b.id, b.boardKind, b.x, b.y, state.components)) continue;
const pin = boardPinToNumber(b.boardKind, pinName);
if (pin !== null) return { pin, boardId: b.id };
}
return null;
}
export function traceDetailed(
state: TraceState,
fromId: string,
fromPin: string,
depth: number,
activeSeen = false,
): { arduinoPin: number | null; crossedActiveDevice: boolean; boardId?: string } {
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
const wires = state.wires.filter(
(w) =>
(w.start.componentId === fromId && w.start.pinName === fromPin) ||
(w.end.componentId === fromId && w.end.pinName === fromPin),
);
// A board SEATED on a socket component is connected without any wire — that
// is what seating means, and it is how the hardware ships: a XIAO pushed
// into a shield's header, a Pi HAT dropped onto the 40-pin. So when this
// component declares a socket (boardSocket, the same contract the magnet
// reads) and a board is seated on it, a pad resolves to the SAME-NAMED pin
// of that board. Without this hop a seated shield's buttons and LEDs were
// dead until the user drew wires that the real stack does not have.
const socketPin = traceThroughSocket(state, fromId, fromPin);
if (socketPin !== null) {
return { arduinoPin: socketPin.pin, crossedActiveDevice: activeSeen, boardId: socketPin.boardId };
}
// Remember a custom-chip neighbour on this net (if any) as a fallback —
// a real board pin found in any branch still takes priority over it.
let chipNeighbour: { id: string; pin: string } | null = null;
for (const w of wires) {
const selfEp =
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
const otherEp = selfEp === w.start ? w.end : w.start;
// A board endpoint is recognised by the LIVE boards list first.
// `isBoardComponent` matches static id prefixes ('arduino-uno', …), which
// only covers the default board — every board added at runtime (the agent
// mints UUID ids) failed the check, so tracing treated it as an unknown
// component and returned null. Symptom: an ESP32 clock whose QEMU was
// emitting hundreds of GPIO edges/second at a display that stayed dark,
// because no resolver ever attached.
const boardEp = state.boards.find((b) => b.id === otherEp.componentId);
if (boardEp || isBoardComponent(otherEp.componentId)) {
const boardKind = boardEp?.boardKind ?? otherEp.componentId;
const pin = boardPinToNumber(boardKind, otherEp.pinName);
// The board id travels with the pin: a QEMU-Linux board has no MCU
// simulator, so an input part needs to know WHICH board's bridge to
// push the level into (see the pi-aware simulator below).
if (pin !== null)
return {
arduinoPin: pin,
crossedActiveDevice: activeSeen,
boardId: boardEp?.id ?? otherEp.componentId,
};
} else {
const comp = state.components.find((c) => c.id === otherEp.componentId);
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
}
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
if (pair) {
const [p1, p2] = pair;
const otherPin = otherEp.pinName === p1 ? p2 : p1;
const nowActive =
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
const result = traceDetailed(
state,
otherEp.componentId,
otherPin,
depth + 1,
nowActive,
);
if (result.arduinoPin !== null) return result;
}
// Breadboards join N holes per internal group (5-hole strip / power
// rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express.
// Continue the trace from every OTHER wired hole in the same group.
//
// Exclusion is by INCOMING WIRE, not by hole name: two wires may
// legitimately share one hole (a seated pin plus a jumper landing in
// that same hole — the agent bridges strips straight into the seat
// hole). Excluding the arrival hole made those stacked connections
// invisible: an ESP32 clock with QEMU firing hundreds of GPIO edges
// per second sat dark because every segment's bridge landed on its
// resistor's own seat hole and the trace dead-ended there.
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
if (bbGroup && comp) {
const groupPins = new Set<string>();
for (const gw of state.wires) {
if (gw.id === w.id) continue; // never bounce back on the same wire
for (const ep of [gw.start, gw.end]) {
if (
ep.componentId === comp.id &&
breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup
) {
groupPins.add(ep.pinName);
}
}
}
for (const groupPin of groupPins) {
const result = traceDetailed(state, comp.id, groupPin, depth + 1, activeSeen);
if (result.arduinoPin !== null) return result;
}
}
}
}
// No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of
// project/multichip-bus/): when this net has two or more chip endpoints and
// no board pin, collapse every endpoint onto ONE net-canonical synthetic key
// so a write on one chip is visible to another through the synchronous
// PinManager fan-out (fixes root cause A: per-endpoint keys never matching).
// resolveChipNetKey returns null when the flag is off, when a board owns the
// net, or when there is a single chip endpoint — so the chip-to-component
// rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the
// starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT
// keeps the same key with no re-trace.
if (depth === 0) {
const netKey = resolveChipNetKey(state, fromId, fromPin);
if (netKey !== null) {
return { arduinoPin: netKey, crossedActiveDevice: activeSeen };
}
}
// No board pin reachable. Fall back to a custom-chip pin on this net so the
// chip can still drive / read it through the synthetic-pin PinManager key.
if (chipNeighbour) {
return {
arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin),
crossedActiveDevice: activeSeen,
};
}
if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') {
return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen };
}
return { arduinoPin: null, crossedActiveDevice: activeSeen };
}
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.
*/
useEffect(() => {
if (!elementRef.current) return;
Object.entries(properties).forEach(([key, value]) => {
try {
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;
} catch (error) {
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
}
});
}, [properties, metadata.tagName]);
/**
* 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;
};
if (tryReseat()) return;
// Same cadence as the pinInfo-ready poll above: the custom element may
// upgrade a few frames after React commits.
const interval = setInterval(() => {
if (tryReseat()) clearInterval(interval);
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 2000);
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 {
(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 = (() => {
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);
}
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 && isPiBoardKind(board.boardKind)) return board.id;
}
return null;
})();
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;
const stubSimulator =
piSimulator ??
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);
}
}
cleanupSimulationEvents = logic.attachEvents(
el,
stubSimulator,
getArduinoPin,
id,
getPinResolver,
);
}
return () => {
if (cleanupSimulationEvents) cleanupSimulationEvents();
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,
};
}