Refactor property synchronization in simulation parts; introduce emitPropertyChange event
- Replaced syncStoreProperty function with emitPropertyChange to decouple parts from Zustand store. - Updated relay component mapping to ensure proper handling of coil and contact states. - Added new test cases for half-wave rectifier and relay-controlled LED to ensure correct functionality. - Introduced InlineComponentSVGs for schematic-style icons of various components. - Updated submodule references for qemu-lcgamboa, rp2040js, and wokwi-elements to indicate dirty state.
This commit is contained in:
parent
9cc9cfebd6
commit
b152cb1919
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"generatedAt": "2026-04-21T04:09:14.073Z",
|
||||
"generatedAt": "2026-04-21T13:02:40.071Z",
|
||||
"components": [
|
||||
{
|
||||
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n DIODE-1N4007\n </text>\n </svg>",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Half-Wave Rectifier — wireElectricalSolver live bootstrap.
|
||||
*
|
||||
* Extracted from `spice-rectifier-live-repro.test.ts` so this describe runs
|
||||
* in its own Vitest worker. The ngspice-WASM engine is a singleton that
|
||||
* holds global heap state; when L1/L3 solves run before this test in the
|
||||
* same process, realloc explodes with "Not enough memory or heap corruption"
|
||||
* and the electrical store falls back to `op` analysis. Isolating the live
|
||||
* bootstrap into its own file gives it a pristine WASM instance.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
function rectifierSnapshot() {
|
||||
return {
|
||||
components: [
|
||||
{
|
||||
id: 'sg1',
|
||||
metadataId: 'signal-generator',
|
||||
properties: { waveform: 'sine', frequency: 50, amplitude: 5, offset: 0 },
|
||||
},
|
||||
{ id: 'd1', metadataId: 'diode-1n4007', properties: {} },
|
||||
{ id: 'rl', metadataId: 'resistor', properties: { value: '1000' } },
|
||||
],
|
||||
wires: [
|
||||
{ id: 'w1', start: { componentId: 'sg1', pinName: 'SIG' }, end: { componentId: 'd1', pinName: 'A' } },
|
||||
{ id: 'w2', start: { componentId: 'd1', pinName: 'C' }, end: { componentId: 'rl', pinName: '1' } },
|
||||
{ id: 'w3', start: { componentId: 'rl', pinName: '2' }, end: { componentId: 'arduino-uno', pinName: 'GND' } },
|
||||
{ id: 'w4', start: { componentId: 'sg1', pinName: 'GND' }, end: { componentId: 'arduino-uno', pinName: 'GND' } },
|
||||
{ id: 'w5', start: { componentId: 'd1', pinName: 'C' }, end: { componentId: 'arduino-uno', pinName: 'A0' } },
|
||||
],
|
||||
boards: [{
|
||||
id: 'arduino-uno',
|
||||
boardKind: 'arduino-uno' as const,
|
||||
pinStates: {},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe('Half-Wave Rectifier — wireElectricalSolver live bootstrap', () => {
|
||||
let rafCallbacks: Array<() => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||
vi.stubGlobal('window', globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function flushRaf() {
|
||||
while (rafCallbacks.length > 0) {
|
||||
const cbs = rafCallbacks.splice(0, rafCallbacks.length);
|
||||
for (const cb of cbs) {
|
||||
try { cb(); } catch (e) { console.warn('RAF cb threw', e); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
it('invokes wireElectricalSolver against live stores populated by loadExample', async () => {
|
||||
const { useSimulatorStore } = await import('../store/useSimulatorStore');
|
||||
const { useElectricalStore } = await import('../store/useElectricalStore');
|
||||
const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore');
|
||||
|
||||
const snap = rectifierSnapshot();
|
||||
const store = useSimulatorStore.getState();
|
||||
|
||||
store.setComponents(
|
||||
snap.components.map((c) => ({
|
||||
id: c.id,
|
||||
metadataId: c.metadataId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
properties: c.properties,
|
||||
})),
|
||||
);
|
||||
store.setWires(
|
||||
snap.wires.map((w) => ({
|
||||
id: w.id,
|
||||
start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 },
|
||||
end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 },
|
||||
color: '#ffaa00',
|
||||
waypoints: [],
|
||||
})),
|
||||
);
|
||||
|
||||
const unsub = wireElectricalSolver();
|
||||
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const es = useElectricalStore.getState();
|
||||
if (es.timeWaveforms) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
const finalES = useElectricalStore.getState();
|
||||
|
||||
const sim = (await import('../store/useSimulatorStore')).getBoardSimulator('arduino-uno');
|
||||
if (sim) {
|
||||
for (let i = 0; i < 10; i++) await flushRaf();
|
||||
}
|
||||
|
||||
unsub();
|
||||
|
||||
expect(finalES.converged).toBe(true);
|
||||
expect(finalES.analysisMode).toBe('tran');
|
||||
expect(finalES.timeWaveforms).toBeDefined();
|
||||
expect(finalES.pinNetMap.size).toBeGreaterThan(0);
|
||||
expect(finalES.pinNetMap.has('arduino-uno:A0')).toBe(true);
|
||||
}, 45_000);
|
||||
});
|
||||
|
|
@ -135,13 +135,17 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => {
|
|||
expect(result.timeWaveforms!.nodes.has(a0Net)).toBe(true);
|
||||
|
||||
// ── L5 ────────────────────────────────────────────────────────────────
|
||||
// `rtw.time[last]` is the `.tran` STOP time (~80 ms — four periods of the
|
||||
// 50 Hz signal), not the signal period. Sample 8 phases across one real
|
||||
// signal period (1/50 Hz = 20 ms); anything else aliases against the sine.
|
||||
console.log('\n=== L5 interpolateAt sanity at 8 phases ===');
|
||||
const rtw = result.timeWaveforms!;
|
||||
const rSamples = rtw.nodes.get(a0Net)!;
|
||||
const periodS = rtw.time[rtw.time.length - 1];
|
||||
const signalFreqHz = 50;
|
||||
const signalPeriodS = 1 / signalFreqHz;
|
||||
const phases: Array<{ t: number; v: number }> = [];
|
||||
for (const q of [0, 1, 2, 3, 4, 5, 6, 7]) {
|
||||
const t = (q / 8) * periodS;
|
||||
const t = (q / 8) * signalPeriodS;
|
||||
const v = interpolateAt(rtw.time, rSamples, t);
|
||||
phases.push({ t, v });
|
||||
console.log(` t = ${(t * 1000).toFixed(2)} ms → V(A0) = ${v.toFixed(3)} V`);
|
||||
|
|
@ -180,7 +184,7 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => {
|
|||
const adchSeries: number[] = [];
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
const simT = freshAvr.cpu.cycles / CPU_HZ;
|
||||
const t = simT % periodS;
|
||||
const t = simT % signalPeriodS;
|
||||
const v = interpolateAt(rtw.time, rSamples, t);
|
||||
setAdcVoltage(freshMock, 14, Math.max(0, Math.min(5, v)));
|
||||
freshAvr.runCycles(STEP_CYCLES);
|
||||
|
|
@ -200,131 +204,12 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => {
|
|||
}, 60_000);
|
||||
});
|
||||
|
||||
// ── L8: live reproduction through the real wireElectricalSolver() ───────
|
||||
// This imports the actual store and solver bootstrap, exactly as EditorPage
|
||||
// does. If the app-level timing / subscription bug exists, this test will
|
||||
// reproduce it here. We stub `requestAnimationFrame` so we can drive replay
|
||||
// frames deterministically.
|
||||
describe('Half-Wave Rectifier — wireElectricalSolver live bootstrap', () => {
|
||||
let rafCallbacks: Array<() => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
||||
// wireElectricalSolver installs window.__spiceDebug — give it a target
|
||||
vi.stubGlobal('window', globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function flushRaf() {
|
||||
// Pop and run any queued callbacks; each one may queue the next frame.
|
||||
while (rafCallbacks.length > 0) {
|
||||
const cbs = rafCallbacks.splice(0, rafCallbacks.length);
|
||||
for (const cb of cbs) {
|
||||
try { cb(); } catch (e) { console.warn('RAF cb threw', e); }
|
||||
}
|
||||
// One trip through the queue per flushRaf call — caller iterates.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
it('invokes wireElectricalSolver against live stores populated by loadExample', async () => {
|
||||
const { useSimulatorStore } = await import('../store/useSimulatorStore');
|
||||
const { useElectricalStore } = await import('../store/useElectricalStore');
|
||||
const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore');
|
||||
|
||||
// Replicate what loadExample() does: setComponents + setWires on the real store.
|
||||
const snap = rectifierSnapshot();
|
||||
console.log('\n=== L8 preparing live store ===');
|
||||
const store = useSimulatorStore.getState();
|
||||
console.log('initial boards:', store.boards.map((b) => ({ id: b.id, kind: b.boardKind })));
|
||||
|
||||
store.setComponents(
|
||||
snap.components.map((c) => ({
|
||||
id: c.id,
|
||||
metadataId: c.metadataId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
properties: c.properties,
|
||||
})),
|
||||
);
|
||||
store.setWires(
|
||||
snap.wires.map((w) => ({
|
||||
id: w.id,
|
||||
start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 },
|
||||
end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 },
|
||||
color: '#ffaa00',
|
||||
waypoints: [],
|
||||
})),
|
||||
);
|
||||
console.log('components set:', useSimulatorStore.getState().components.map((c) => c.id));
|
||||
console.log('wires set:', useSimulatorStore.getState().wires.length);
|
||||
|
||||
// Now mount wireElectricalSolver — exactly as EditorPage useEffect does.
|
||||
console.log('\n=== L8 calling wireElectricalSolver() ===');
|
||||
const unsub = wireElectricalSolver();
|
||||
|
||||
// Give the debounced solve (50ms) + async ngspice time to complete.
|
||||
// Poll the store until timeWaveforms appears or we time out.
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const es = useElectricalStore.getState();
|
||||
if (es.timeWaveforms) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
const finalES = useElectricalStore.getState();
|
||||
console.log('\n=== L8 electrical store after solve ===');
|
||||
console.log('analysisMode:', finalES.analysisMode);
|
||||
console.log('converged:', finalES.converged, 'error:', finalES.error);
|
||||
console.log('pinNetMap size:', finalES.pinNetMap.size, 'entries:', [...finalES.pinNetMap.entries()].slice(0, 16));
|
||||
console.log('hasTimeWaveforms:', !!finalES.timeWaveforms);
|
||||
if (finalES.timeWaveforms) {
|
||||
console.log('timeWaveforms.nodes keys:', [...finalES.timeWaveforms.nodes.keys()]);
|
||||
const a0Net = finalES.pinNetMap.get('arduino-uno:A0');
|
||||
console.log('arduino-uno:A0 → net:', a0Net);
|
||||
if (a0Net) {
|
||||
const samples = finalES.timeWaveforms.nodes.get(a0Net);
|
||||
if (samples) {
|
||||
console.log(`samples @ A0 net: peak=${Math.max(...samples).toFixed(3)} V, count=${samples.length}`);
|
||||
} else {
|
||||
console.log('!!! a0Net has no samples in timeWaveforms.nodes !!!');
|
||||
}
|
||||
} else {
|
||||
console.log('!!! pinNetMap does not contain arduino-uno:A0 !!!');
|
||||
}
|
||||
}
|
||||
console.log('RAF queued frames:', rafCallbacks.length);
|
||||
|
||||
// Drain some RAF frames to confirm the replay actually writes into AVRADC.
|
||||
const sim = (await import('../store/useSimulatorStore')).getBoardSimulator('arduino-uno');
|
||||
console.log('live simulator:', sim ? 'present' : 'absent');
|
||||
if (sim) {
|
||||
const adc = (sim as unknown as { getADC: () => { channelValues: Float32Array | number[] } }).getADC();
|
||||
console.log('ADC channelValues BEFORE RAF:', adc ? [...adc.channelValues].slice(0, 6) : 'no adc');
|
||||
// Drive 10 RAF frames simulating ~160ms of real time
|
||||
for (let i = 0; i < 10; i++) await flushRaf();
|
||||
console.log('RAF callbacks after drain:', rafCallbacks.length);
|
||||
console.log('ADC channelValues AFTER RAF:', adc ? [...adc.channelValues].slice(0, 6) : 'no adc');
|
||||
}
|
||||
|
||||
unsub();
|
||||
|
||||
// Assertions — the pipeline should have produced a valid waveform.
|
||||
expect(finalES.converged).toBe(true);
|
||||
expect(finalES.analysisMode).toBe('tran');
|
||||
expect(finalES.timeWaveforms).toBeDefined();
|
||||
expect(finalES.pinNetMap.size).toBeGreaterThan(0);
|
||||
expect(finalES.pinNetMap.has('arduino-uno:A0')).toBe(true);
|
||||
}, 45_000);
|
||||
});
|
||||
// ── L8 extracted to `spice-rectifier-live-bootstrap.test.ts` ─────────────
|
||||
// The live-bootstrap block ran against the real singleton ngspice-WASM
|
||||
// engine. When L1/L3 solved first in the same process, realloc exploded
|
||||
// with "Not enough memory or heap corruption" and the electrical store
|
||||
// fell back to `op`. Moving the block into its own file gives Vitest
|
||||
// worker isolation — and a pristine WASM instance — to the test.
|
||||
|
||||
// ── L9: per-read onADCRead hook (RAF replay removed in Phase 1) ──────────
|
||||
// The previous version of this block flushed RAF frames and expected
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Regression: Relay-Controlled LED example (examples-circuits.ts
|
||||
* `relay-led-switch`). Covers two historical bugs:
|
||||
* 1) relay mapper returned null when NC was unwired → no relay cards
|
||||
* emitted at all, LED stuck off regardless of coil drive.
|
||||
* 2) coil was R || L instead of R — L — in series; at .op the L shorted
|
||||
* the R, V(COIL+) ≡ V(COIL-), switch control was 0, NO never closed.
|
||||
*
|
||||
* Circuit:
|
||||
* pin 9 → Rb(1k) → Q1(2N2222).B
|
||||
* 5V → relay.COIL+ ; relay.COIL- → Q1.C ; Q1.E → GND
|
||||
* 5V → relay.NO ; relay.COM → Rl(220) → LED.A ; LED.C → GND
|
||||
* relay.NC left unconnected (normal pattern)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
|
||||
import { runNetlist } from '../simulation/spice/SpiceEngine';
|
||||
import type { BuildNetlistInput, PinSourceState } from '../simulation/spice/types';
|
||||
|
||||
function relayWires() {
|
||||
return [
|
||||
{ id: 'w1', start: { componentId: 'arduino-uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' } },
|
||||
{ id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' } },
|
||||
{ id: 'w3', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'rly', pinName: 'COIL+' } },
|
||||
{ id: 'w4', start: { componentId: 'rly', pinName: 'COIL-' }, end: { componentId: 'q1', pinName: 'C' } },
|
||||
{ id: 'w5', start: { componentId: 'q1', pinName: 'E' }, end: { componentId: 'arduino-uno', pinName: 'GND' } },
|
||||
{ id: 'w6', start: { componentId: 'arduino-uno', pinName: '5V' }, end: { componentId: 'rly', pinName: 'NO' } },
|
||||
{ id: 'w7', start: { componentId: 'rly', pinName: 'COM' }, end: { componentId: 'rl', pinName: '1' } },
|
||||
{ id: 'w8', start: { componentId: 'rl', pinName: '2' }, end: { componentId: 'led1', pinName: 'A' } },
|
||||
{ id: 'w9', start: { componentId: 'led1', pinName: 'C' }, end: { componentId: 'arduino-uno', pinName: 'GND' } },
|
||||
];
|
||||
}
|
||||
|
||||
function relayInput(pinStates: Record<string, PinSourceState>): BuildNetlistInput {
|
||||
return {
|
||||
components: [
|
||||
{ id: 'rb', metadataId: 'resistor', properties: { value: '1000' } },
|
||||
{ id: 'q1', metadataId: 'bjt-2n2222', properties: {} },
|
||||
{ id: 'rly', metadataId: 'relay', properties: { coil_voltage: 5 } },
|
||||
{ id: 'rl', metadataId: 'resistor', properties: { value: '220' } },
|
||||
{ id: 'led1', metadataId: 'led', properties: { color: 'red' } },
|
||||
],
|
||||
wires: relayWires(),
|
||||
boards: [{
|
||||
id: 'arduino-uno',
|
||||
vcc: 5,
|
||||
pins: pinStates,
|
||||
groundPinNames: ['GND'],
|
||||
vccPinNames: ['5V'],
|
||||
}],
|
||||
analysis: { kind: 'op' },
|
||||
};
|
||||
}
|
||||
|
||||
describe('Relay-Controlled LED — SPICE integration', () => {
|
||||
it('coil energised when pin 9 HIGH → NO closes → LED lights', { timeout: 60_000 }, async () => {
|
||||
const { netlist } = buildNetlist(relayInput({ '9': { type: 'digital', v: 5 } }));
|
||||
expect(netlist).toMatch(/R_rly_coil\b/);
|
||||
expect(netlist).toMatch(/S_rly_no\b/);
|
||||
const cooked = await runNetlist(netlist);
|
||||
const iLed = Math.abs(cooked.dcValue('i(v_led1_sense)'));
|
||||
expect(iLed).toBeGreaterThan(5e-3);
|
||||
});
|
||||
|
||||
it('coil idle when pin 9 LOW → NO open → LED dark', { timeout: 60_000 }, async () => {
|
||||
const { netlist } = buildNetlist(relayInput({}));
|
||||
const cooked = await runNetlist(netlist);
|
||||
const iLed = Math.abs(cooked.dcValue('i(v_led1_sense)'));
|
||||
expect(iLed).toBeLessThan(1e-6);
|
||||
});
|
||||
});
|
||||
|
|
@ -176,6 +176,40 @@
|
|||
align-content: start;
|
||||
}
|
||||
|
||||
/* Single scroll container wrapping the boards row + components grid in the
|
||||
"All Components" view, so the modal shows only one scrollbar instead of
|
||||
two stacked ones. */
|
||||
.components-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.components-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.components-scroll::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.components-scroll::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.components-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #999;
|
||||
}
|
||||
|
||||
/* When a grid lives inside .components-scroll it should NOT scroll on its
|
||||
own — the wrapper handles all scrolling. */
|
||||
.components-grid--inline {
|
||||
flex: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
|
|
@ -412,11 +446,13 @@
|
|||
color: #888;
|
||||
}
|
||||
|
||||
.components-grid::-webkit-scrollbar-track {
|
||||
.components-grid::-webkit-scrollbar-track,
|
||||
.components-scroll::-webkit-scrollbar-track {
|
||||
background: #3d3d3d;
|
||||
}
|
||||
|
||||
.components-grid::-webkit-scrollbar-thumb {
|
||||
.components-grid::-webkit-scrollbar-thumb,
|
||||
.components-scroll::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,52 +185,55 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Boards row in "All Components" view */}
|
||||
{selectedCategory === 'all' && onSelectBoard && (
|
||||
<div className="components-grid" style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}>
|
||||
{ALL_BOARDS.filter((k) =>
|
||||
!searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase())
|
||||
).map((kind) => (
|
||||
<BoardCard
|
||||
key={kind}
|
||||
kind={kind}
|
||||
onSelect={() => { onSelectBoard(kind); onClose(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Components Grid */}
|
||||
<div className="components-grid">
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading components...</p>
|
||||
{/* Single scrollable area wrapping both the boards row (only in
|
||||
"All Components" view) and the components grid, so the modal
|
||||
shows ONE scrollbar instead of two stacked ones. */}
|
||||
<div className="components-scroll">
|
||||
{selectedCategory === 'all' && onSelectBoard && (
|
||||
<div className="components-grid components-grid--inline" style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}>
|
||||
{ALL_BOARDS.filter((k) =>
|
||||
!searchQuery || BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase())
|
||||
).map((kind) => (
|
||||
<BoardCard
|
||||
key={kind}
|
||||
kind={kind}
|
||||
onSelect={() => { onSelectBoard(kind); onClose(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredComponents.length === 0 ? (
|
||||
<div className="no-results">
|
||||
<p>No components found</p>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-filters-btn"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredComponents.map((component) => (
|
||||
<ComponentCard
|
||||
key={component.id}
|
||||
component={component}
|
||||
onSelect={() => onSelectComponent(component)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="components-grid components-grid--inline">
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading components...</p>
|
||||
</div>
|
||||
) : filteredComponents.length === 0 ? (
|
||||
<div className="no-results">
|
||||
<p>No components found</p>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-filters-btn"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredComponents.map((component) => (
|
||||
<ComponentCard
|
||||
key={component.id}
|
||||
component={component}
|
||||
onSelect={() => onSelectComponent(component)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@
|
|||
*/
|
||||
|
||||
// ─── Shared colours ───────────────────────────────────────────────────────────
|
||||
const FILL = '#f4f0e8';
|
||||
const STROKE = '#2a2a2a';
|
||||
const LEAD = '#555555';
|
||||
const LABEL = '#333333';
|
||||
// Tuned for the dark (#1a1a1a) simulator canvas — symbols must read as
|
||||
// light schematic strokes, not dark-on-dark.
|
||||
const STROKE = '#e6e6e6'; // primary symbol strokes (base bar, channel)
|
||||
const LEAD = '#b8b8b8'; // pin leads
|
||||
const LABEL = '#d0d0d0'; // pin letters / part number
|
||||
const BODY = '#7a7a7a'; // optional body-circle outline
|
||||
const STYLE = ':host{display:inline-block;line-height:0}';
|
||||
|
||||
function threePinInfo(pins: Array<{ name: string; x: number; y: number; number: number }>) {
|
||||
|
|
@ -42,39 +44,36 @@ function threePinInfo(pins: Array<{ name: string; x: number; y: number; number:
|
|||
// Arrow on emitter lead segment (46..56, y=50..56) — NPN points outward, PNP inward.
|
||||
|
||||
function bjtSvg(arrowDir: 'npn' | 'pnp', text: string): string {
|
||||
// Emitter arrow: two line pairs forming an arrowhead near the emitter segment
|
||||
// Symmetric triangle arrowhead on the horizontal emitter line at y=48.
|
||||
// NPN points OUT (rightward, away from base); PNP points IN (leftward, toward base).
|
||||
const arrowhead =
|
||||
arrowDir === 'npn'
|
||||
? `
|
||||
<!-- NPN: arrow points OUT from base, toward emitter -->
|
||||
<polygon points="40,52 46,48 44,55" fill="${STROKE}"/>`
|
||||
: `
|
||||
<!-- PNP: arrow points IN toward base -->
|
||||
<polygon points="28,44 22,48 26,51" fill="${STROKE}"/>`;
|
||||
? `<polygon points="44,48 36,44 36,52" fill="${STROKE}"/>`
|
||||
: `<polygon points="22,48 30,44 30,52" fill="${STROKE}"/>`;
|
||||
|
||||
return `
|
||||
<style>${STYLE}</style>
|
||||
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Base -->
|
||||
<line x1="0" y1="36" x2="22" y2="36" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="22" y1="14" x2="22" y2="58" stroke="${STROKE}" stroke-width="2.5"/>
|
||||
<!-- Body circle (clean outline, no fill — TO-92 hint) -->
|
||||
<circle cx="34" cy="36" r="22" fill="none" stroke="${BODY}" stroke-width="1.2"/>
|
||||
<!-- Base lead + bar -->
|
||||
<line x1="0" y1="36" x2="22" y2="36" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="22" y1="14" x2="22" y2="58" stroke="${STROKE}" stroke-width="3"/>
|
||||
<!-- Collector side -->
|
||||
<line x1="22" y1="24" x2="46" y2="24" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="22" y1="24" x2="46" y2="24" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="46" y1="24" x2="46" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="46" y1="6" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="60" y1="0" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<!-- Emitter side -->
|
||||
<line x1="22" y1="48" x2="46" y2="48" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="22" y1="48" x2="46" y2="48" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="46" y1="48" x2="46" y2="66" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="46" y1="66" x2="60" y2="66" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="60" y1="66" x2="60" y2="72" stroke="${LEAD}" stroke-width="2"/>
|
||||
${arrowhead}
|
||||
<!-- Body circle (optional TO-92 style) -->
|
||||
<circle cx="34" cy="36" r="22" fill="${FILL}" fill-opacity="0.2" stroke="${STROKE}" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<!-- Pin labels -->
|
||||
<text x="50" y="14" font-family="sans-serif" font-size="7" fill="${LABEL}">C</text>
|
||||
<text x="2" y="32" font-family="sans-serif" font-size="7" fill="${LABEL}">B</text>
|
||||
<text x="50" y="64" font-family="sans-serif" font-size="7" fill="${LABEL}">E</text>
|
||||
<text x="50" y="14" font-family="sans-serif" font-size="8" fill="${LABEL}">C</text>
|
||||
<text x="2" y="32" font-family="sans-serif" font-size="8" fill="${LABEL}">B</text>
|
||||
<text x="50" y="64" font-family="sans-serif" font-size="8" fill="${LABEL}">E</text>
|
||||
<!-- Part number -->
|
||||
<text x="36" y="78" text-anchor="middle" font-family="sans-serif" font-size="7" fill="${LABEL}" font-weight="bold">${text}</text>
|
||||
</svg>`;
|
||||
|
|
@ -104,37 +103,40 @@ function makeBjtClass(label: string, polarity: 'npn' | 'pnp') {
|
|||
// for PMOS points FROM channel OUT toward substrate.
|
||||
|
||||
function mosfetSvg(polarity: 'nmos' | 'pmos', text: string): string {
|
||||
// Symmetric arrowhead between gate plate and channel.
|
||||
// NMOS: arrow points INTO the channel (rightward).
|
||||
// PMOS: arrow points AWAY from channel (leftward).
|
||||
const arrow = polarity === 'nmos'
|
||||
? `<polygon points="24,36 32,32 32,40" fill="${STROKE}"/>` // NMOS arrow into channel
|
||||
: `<polygon points="32,36 24,32 24,40" fill="${STROKE}"/>`; // PMOS arrow away
|
||||
? `<polygon points="24,36 18,32 18,40" fill="${STROKE}"/>`
|
||||
: `<polygon points="18,36 24,32 24,40" fill="${STROKE}"/>`;
|
||||
|
||||
return `
|
||||
<style>${STYLE}</style>
|
||||
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Body circle (clean outline only) -->
|
||||
<circle cx="34" cy="36" r="22" fill="none" stroke="${BODY}" stroke-width="1.2"/>
|
||||
<!-- Gate lead and gate plate -->
|
||||
<line x1="0" y1="36" x2="18" y2="36" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="18" y1="22" x2="18" y2="50" stroke="${STROKE}" stroke-width="2.5"/>
|
||||
<!-- Channel line (drain/source side, with small breaks to suggest enhancement mode) -->
|
||||
<line x1="24" y1="22" x2="24" y2="28" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="24" y1="32" x2="24" y2="40" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="24" y1="44" x2="24" y2="50" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="0" y1="36" x2="16" y2="36" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="16" y1="22" x2="16" y2="50" stroke="${STROKE}" stroke-width="3"/>
|
||||
<!-- Channel line with breaks (enhancement-mode hint) -->
|
||||
<line x1="24" y1="22" x2="24" y2="28" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="24" y1="32" x2="24" y2="40" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="24" y1="44" x2="24" y2="50" stroke="${STROKE}" stroke-width="2"/>
|
||||
<!-- Drain side -->
|
||||
<line x1="24" y1="22" x2="46" y2="22" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<line x1="24" y1="22" x2="46" y2="22" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="46" y1="22" x2="46" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="46" y1="6" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="60" y1="0" x2="60" y2="6" stroke="${LEAD}" stroke-width="2"/>
|
||||
<!-- Source side + body tie (short horizontal back to channel) -->
|
||||
<line x1="24" y1="50" x2="46" y2="50" stroke="${STROKE}" stroke-width="1.5"/>
|
||||
<!-- Source side -->
|
||||
<line x1="24" y1="50" x2="46" y2="50" stroke="${STROKE}" stroke-width="2"/>
|
||||
<line x1="46" y1="50" x2="46" y2="66" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="46" y1="66" x2="60" y2="66" stroke="${LEAD}" stroke-width="2"/>
|
||||
<line x1="60" y1="66" x2="60" y2="72" stroke="${LEAD}" stroke-width="2"/>
|
||||
${arrow}
|
||||
<!-- Body outline -->
|
||||
<circle cx="34" cy="36" r="22" fill="${FILL}" fill-opacity="0.2" stroke="${STROKE}" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<!-- Pin labels -->
|
||||
<text x="50" y="14" font-family="sans-serif" font-size="7" fill="${LABEL}">D</text>
|
||||
<text x="2" y="32" font-family="sans-serif" font-size="7" fill="${LABEL}">G</text>
|
||||
<text x="50" y="64" font-family="sans-serif" font-size="7" fill="${LABEL}">S</text>
|
||||
<text x="50" y="14" font-family="sans-serif" font-size="8" fill="${LABEL}">D</text>
|
||||
<text x="2" y="32" font-family="sans-serif" font-size="8" fill="${LABEL}">G</text>
|
||||
<text x="50" y="64" font-family="sans-serif" font-size="8" fill="${LABEL}">S</text>
|
||||
<!-- Part number -->
|
||||
<text x="36" y="78" text-anchor="middle" font-family="sans-serif" font-size="7" fill="${LABEL}" font-weight="bold">${text}</text>
|
||||
</svg>`;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@
|
|||
|
||||
import React from 'react';
|
||||
import type { ExampleProject, ExampleBoard } from '../../data/examples';
|
||||
import { INLINE_SVGS } from './InlineComponentSVGs';
|
||||
|
||||
// ── Natural display sizes (px on the simulator canvas) ──────────────────────
|
||||
// A CompDef either points to a static file under /component-svgs/ (svg set) or
|
||||
// to an inline React component rendered via an inline <svg> (inline set).
|
||||
interface CompDef {
|
||||
svg: string; // filename under /component-svgs/
|
||||
svg: string; // filename under /component-svgs/ (empty string for inline)
|
||||
inline?: React.FC<{ w: number; h: number }>;
|
||||
w: number; // natural width in canvas-space pixels
|
||||
h: number; // natural height in canvas-space pixels
|
||||
}
|
||||
|
|
@ -87,9 +91,20 @@ function getCompDef(type: string, props: Record<string, any>): CompDef {
|
|||
const colorSvg = LED_COLOR_SVG[(props.color as string)?.toLowerCase()] ?? 'wokwi-led.svg';
|
||||
return { ...COMP_DEFS['wokwi-led'], svg: colorSvg };
|
||||
}
|
||||
return COMP_DEFS[type] ?? { svg: '', w: 50, h: 50 };
|
||||
if (COMP_DEFS[type]) return COMP_DEFS[type];
|
||||
const inline = INLINE_SVGS[type];
|
||||
if (inline) return { svg: '', inline: inline.component, w: inline.w, h: inline.h };
|
||||
// Unknown type — fall back to a small generic labeled box so it's visible.
|
||||
return { svg: '', inline: unknownGlyph, w: 60, h: 40 };
|
||||
}
|
||||
|
||||
const unknownGlyph: React.FC<{ w: number; h: number }> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="56" height="36" rx="3" fill="#3b3b3b" stroke="#888" strokeWidth="1" strokeDasharray="3,2"/>
|
||||
<text x="30" y="24" textAnchor="middle" fontSize="9" fill="#ccc">?</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Whether a component type is the main board (already registered via boardType)
|
||||
function isBoardType(type: string): boolean {
|
||||
return type.includes('arduino-uno') ||
|
||||
|
|
@ -116,6 +131,49 @@ interface LayoutItem {
|
|||
x: number;
|
||||
y: number;
|
||||
def: CompDef;
|
||||
fixed?: boolean; // boards don't move during overlap resolution
|
||||
}
|
||||
|
||||
/**
|
||||
* Relax the layout so no two items overlap. Items declared `fixed` (boards)
|
||||
* act as anchors; non-fixed items are pushed out along whichever axis needs
|
||||
* the smaller shift. Runs up to MAX_ITER passes — convergence is fast for
|
||||
* the 3–20 item preview circuits.
|
||||
*/
|
||||
function resolveOverlaps(items: LayoutItem[], gap = 8): void {
|
||||
const MAX_ITER = 30;
|
||||
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
for (let j = i + 1; j < items.length; j++) {
|
||||
const a = items[i];
|
||||
const b = items[j];
|
||||
const ax1 = a.x - gap, ax2 = a.x + a.def.w + gap;
|
||||
const ay1 = a.y - gap, ay2 = a.y + a.def.h + gap;
|
||||
const bx1 = b.x, bx2 = b.x + b.def.w;
|
||||
const by1 = b.y, by2 = b.y + b.def.h;
|
||||
const overlapX = Math.min(ax2, bx2) - Math.max(ax1, bx1);
|
||||
const overlapY = Math.min(ay2, by2) - Math.max(ay1, by1);
|
||||
if (overlapX <= 0 || overlapY <= 0) continue;
|
||||
// Decide which one to move. Fixed (board) never moves; otherwise move `b`.
|
||||
const target = a.fixed ? b : (b.fixed ? a : b);
|
||||
const anchor = target === a ? b : a;
|
||||
if (target.fixed) continue; // both fixed — nothing we can do
|
||||
// Shift along the axis of least displacement
|
||||
if (overlapX < overlapY) {
|
||||
const aCx = anchor.x + anchor.def.w / 2;
|
||||
const tCx = target.x + target.def.w / 2;
|
||||
target.x += tCx < aCx ? -overlapX : overlapX;
|
||||
} else {
|
||||
const aCy = anchor.y + anchor.def.h / 2;
|
||||
const tCy = target.y + target.def.h / 2;
|
||||
target.y += tCy < aCy ? -overlapY : overlapY;
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) return;
|
||||
}
|
||||
}
|
||||
|
||||
export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
|
||||
|
|
@ -133,7 +191,7 @@ export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
|
|||
// Multi-board layout
|
||||
example.boards.forEach((b: ExampleBoard) => {
|
||||
const def = BOARD_DEFS[b.boardKind] ?? { svg: '', w: 200, h: 140 };
|
||||
items.push({ id: b.boardKind, x: b.x, y: b.y, def });
|
||||
items.push({ id: b.boardKind, x: b.x, y: b.y, def, fixed: true });
|
||||
});
|
||||
} else {
|
||||
const boardKind = example.boardType ?? 'arduino-uno';
|
||||
|
|
@ -153,7 +211,7 @@ export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
|
|||
: 150;
|
||||
const boardX = Math.max(40, minCompX - boardDef.w - 60);
|
||||
const boardY = Math.max(40, avgCompY - boardDef.h / 2);
|
||||
items.push({ id: boardKind + '-board', x: boardX, y: boardY, def: boardDef });
|
||||
items.push({ id: boardKind + '-board', x: boardX, y: boardY, def: boardDef, fixed: true });
|
||||
}
|
||||
|
||||
// Add all components from the example
|
||||
|
|
@ -162,10 +220,16 @@ export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
|
|||
const def = isBoardType(c.type) && boardDef
|
||||
? boardDef
|
||||
: getCompDef(c.type, c.properties ?? {});
|
||||
items.push({ id: c.id, x: c.x, y: c.y, def });
|
||||
const fixed = isBoardType(c.type);
|
||||
items.push({ id: c.id, x: c.x, y: c.y, def, fixed });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Push overlapping components apart so nothing sits on top of the board
|
||||
// or another component in the preview (component sizes in inline SVG
|
||||
// renderers may not exactly match the authored canvas positions).
|
||||
resolveOverlaps(items);
|
||||
|
||||
// ── Bounding box & scale ─────────────────────────────────────────────────
|
||||
const PAD = 12;
|
||||
|
||||
|
|
@ -222,26 +286,34 @@ export const CircuitPreview: React.FC<CircuitPreviewProps> = ({
|
|||
>
|
||||
{/* ── Component images ─────────────────────────────────────────────── */}
|
||||
{items.map(({ id, x, y, def }) => {
|
||||
if (!def.svg) return null;
|
||||
const px = x * scale + dx;
|
||||
const py = y * scale + dy;
|
||||
const pw = def.w * scale;
|
||||
const ph = def.h * scale;
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: px,
|
||||
top: py,
|
||||
width: pw,
|
||||
height: ph,
|
||||
imageRendering: 'auto',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
|
||||
};
|
||||
if (def.inline) {
|
||||
const Inline = def.inline;
|
||||
return (
|
||||
<div key={id} style={wrapperStyle}>
|
||||
<Inline w={pw} h={ph} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!def.svg) return null;
|
||||
return (
|
||||
<img
|
||||
key={id}
|
||||
src={`/component-svgs/${def.svg}`}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: px,
|
||||
top: py,
|
||||
width: pw,
|
||||
height: ph,
|
||||
imageRendering: 'auto',
|
||||
// Filter to slightly darken components so they read well on dark bg
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
|
||||
}}
|
||||
style={wrapperStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* InlineComponentSVGs — simple schematic-style icons for the components
|
||||
* whose SVG isn't pre-rendered into /component-svgs/ (transistors, MOSFETs,
|
||||
* diodes, capacitors, relays, optocouplers, op-amps, logic gates, signal
|
||||
* generators, batteries, regulators, motor drivers, etc.).
|
||||
*
|
||||
* Used by CircuitPreview.tsx to keep the /examples gallery cards visually
|
||||
* representative even for parts that weren't extracted from wokwi-elements.
|
||||
*
|
||||
* Each renderer receives {w, h} (canvas-space size) and fills its box with
|
||||
* a recognizable schematic glyph. Sizes chosen to match the on-canvas size
|
||||
* of the corresponding custom web component, so bounding boxes line up.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
interface InlineSVGProps {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// ─── Transistors ───────────────────────────────────────────────────────────
|
||||
const BjtNpn: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
|
||||
<line x1="0" y1="36" x2="22" y2="36" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="20" x2="22" y2="52" stroke="#222" strokeWidth="3"/>
|
||||
<line x1="22" y1="24" x2="44" y2="6" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="44" y1="6" x2="60" y2="6" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="48" x2="44" y2="66" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="44" y1="66" x2="60" y2="66" stroke="#555" strokeWidth="2"/>
|
||||
<polygon points="40,60 44,66 36,65" fill="#222"/>
|
||||
<text x="50" y="40" fontSize="8" fill="#333">NPN</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BjtPnp: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
|
||||
<line x1="0" y1="36" x2="22" y2="36" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="20" x2="22" y2="52" stroke="#222" strokeWidth="3"/>
|
||||
<line x1="22" y1="24" x2="44" y2="6" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="44" y1="6" x2="60" y2="6" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="48" x2="44" y2="66" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="44" y1="66" x2="60" y2="66" stroke="#555" strokeWidth="2"/>
|
||||
<polygon points="26,28 22,24 32,26" fill="#222"/>
|
||||
<text x="50" y="40" fontSize="8" fill="#333">PNP</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Mosfet: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="36" cy="36" r="24" fill="#f6f1e8" stroke="#555" strokeWidth="1"/>
|
||||
<line x1="0" y1="36" x2="20" y2="36" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="20" y1="22" x2="20" y2="50" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="24" y1="20" x2="24" y2="32" stroke="#222" strokeWidth="3"/>
|
||||
<line x1="24" y1="40" x2="24" y2="52" stroke="#222" strokeWidth="3"/>
|
||||
<line x1="24" y1="26" x2="48" y2="8" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="48" y1="8" x2="60" y2="8" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="24" y1="46" x2="48" y2="64" stroke="#222" strokeWidth="2"/>
|
||||
<line x1="48" y1="64" x2="60" y2="64" stroke="#555" strokeWidth="2"/>
|
||||
<polygon points="32,40 28,36 32,32" fill="#222"/>
|
||||
<text x="46" y="42" fontSize="8" fill="#333">MOS</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Diodes ────────────────────────────────────────────────────────────────
|
||||
function diodeGlyph(label: string, w: number, h: number): React.ReactElement {
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 72 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="14" y="12" width="44" height="16" rx="2" fill="#2a2a2a" stroke="#111" strokeWidth="1"/>
|
||||
<rect x="18" y="12" width="4" height="16" fill="#eee"/>
|
||||
<line x1="0" y1="20" x2="14" y2="20" stroke="#888" strokeWidth="2"/>
|
||||
<line x1="58" y1="20" x2="72" y2="20" stroke="#888" strokeWidth="2"/>
|
||||
<text x="36" y="25" fontSize="8" fill="#eee" textAnchor="middle" fontFamily="sans-serif">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const Diode1N4007: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('1N4007', w, h);
|
||||
const Diode1N5817: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('1N5817', w, h);
|
||||
const DiodeZener: React.FC<InlineSVGProps> = ({ w, h }) => diodeGlyph('ZD', w, h);
|
||||
|
||||
// ─── Passives ──────────────────────────────────────────────────────────────
|
||||
const Capacitor: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 56 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0" y1="18" x2="22" y2="18" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="34" y1="18" x2="56" y2="18" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="6" x2="22" y2="30" stroke="#222" strokeWidth="3"/>
|
||||
<line x1="34" y1="6" x2="34" y2="30" stroke="#222" strokeWidth="3"/>
|
||||
<text x="28" y="34" fontSize="6" fill="#666" textAnchor="middle">C</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Relay ─────────────────────────────────────────────────────────────────
|
||||
const Relay: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="8" width="76" height="80" rx="4" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
<line x1="0" y1="16" x2="18" y2="16" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="0" y1="80" x2="18" y2="80" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="22" y1="16" x2="22" y2="80" stroke="#8a5a00" strokeWidth="1.5"/>
|
||||
{[24, 34, 44, 54, 64, 74].map(cy => (
|
||||
<circle key={cy} cx="22" cy={cy} r="3" fill="none" stroke="#8a5a00" strokeWidth="1.5"/>
|
||||
))}
|
||||
<line x1="60" y1="48" x2="80" y2="48" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="72" y1="20" x2="86" y2="16" stroke="#2a2a2a" strokeWidth="2"/>
|
||||
<line x1="86" y1="16" x2="96" y2="16" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="86" y1="80" x2="96" y2="80" stroke="#555" strokeWidth="2"/>
|
||||
<text x="48" y="54" textAnchor="middle" fontSize="9" fill="#333" fontWeight="bold">RELAY</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Optocoupler ───────────────────────────────────────────────────────────
|
||||
function optoGlyph(label: string, w: number, h: number): React.ReactElement {
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 80 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="8" width="60" height="48" rx="3" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
<line x1="40" y1="8" x2="40" y2="56" stroke="#2a2a2a" strokeWidth="0.8" strokeDasharray="2,2"/>
|
||||
<polygon points="18,22 18,38 30,30" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.2"/>
|
||||
<line x1="30" y1="22" x2="30" y2="38" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
<line x1="46" y1="20" x2="46" y2="40" stroke="#2a2a2a" strokeWidth="2"/>
|
||||
<line x1="46" y1="25" x2="58" y2="18" stroke="#2a2a2a" strokeWidth="1.2"/>
|
||||
<line x1="46" y1="35" x2="58" y2="42" stroke="#2a2a2a" strokeWidth="1.2"/>
|
||||
<line x1="0" y1="16" x2="10" y2="16" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="0" y1="48" x2="10" y2="48" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="70" y1="16" x2="80" y2="16" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="70" y1="48" x2="80" y2="48" stroke="#555" strokeWidth="2"/>
|
||||
<text x="40" y="62" textAnchor="middle" fontSize="7" fill="#333" fontWeight="bold">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const Opto4N25: React.FC<InlineSVGProps> = ({ w, h }) => optoGlyph('4N25', w, h);
|
||||
|
||||
// ─── DIP IC block (motor driver, etc.) ─────────────────────────────────────
|
||||
function dipGlyph(label: string, w: number, h: number): React.ReactElement {
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 100 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="4" width="80" height="72" rx="4" fill="#1f2937" stroke="#eef3fa" strokeWidth="1"/>
|
||||
<circle cx="18" cy="12" r="2" fill="#eef3fa"/>
|
||||
{[12, 20, 28, 36, 44, 52, 60, 68].map(y => (
|
||||
<React.Fragment key={y}>
|
||||
<line x1="0" y1={y} x2="10" y2={y} stroke="#bbb" strokeWidth="1.5"/>
|
||||
<line x1="90" y1={y} x2="100" y2={y} stroke="#bbb" strokeWidth="1.5"/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<text x="50" y="44" textAnchor="middle" fontSize="10" fill="#e5e7eb" fontWeight="bold">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const MotorDriverL293D: React.FC<InlineSVGProps> = ({ w, h }) => dipGlyph('L293D', w, h);
|
||||
|
||||
// ─── Op-amp ────────────────────────────────────────────────────────────────
|
||||
function opampGlyph(label: string, w: number, h: number): React.ReactElement {
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 80 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="20,8 20,64 66,36" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
<line x1="0" y1="22" x2="20" y2="22" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="0" y1="50" x2="20" y2="50" stroke="#555" strokeWidth="2"/>
|
||||
<line x1="66" y1="36" x2="80" y2="36" stroke="#555" strokeWidth="2"/>
|
||||
<text x="26" y="26" fontSize="10" fill="#333" fontWeight="bold">−</text>
|
||||
<text x="26" y="55" fontSize="10" fill="#333" fontWeight="bold">+</text>
|
||||
<text x="40" y="68" textAnchor="middle" fontSize="7" fill="#666">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const OpampLM358: React.FC<InlineSVGProps> = ({ w, h }) => opampGlyph('LM358', w, h);
|
||||
|
||||
// ─── Logic gates ───────────────────────────────────────────────────────────
|
||||
function gateShape(
|
||||
shape: 'and' | 'nand' | 'or' | 'nor' | 'xor' | 'xnor' | 'not',
|
||||
w: number,
|
||||
h: number,
|
||||
): React.ReactElement {
|
||||
const negated = shape === 'nand' || shape === 'nor' || shape === 'xnor' || shape === 'not';
|
||||
const exclusive = shape === 'xor' || shape === 'xnor';
|
||||
const isOr = shape === 'or' || shape === 'nor' || shape === 'xor' || shape === 'xnor';
|
||||
const isNot = shape === 'not';
|
||||
const body = isNot
|
||||
? <polygon points="12,10 12,38 46,24" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
: isOr
|
||||
? <path d="M 10 8 Q 28 24 10 40 Q 38 40 54 24 Q 38 8 10 8 Z" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>
|
||||
: <path d="M 10 8 L 30 8 Q 54 8 54 24 Q 54 40 30 40 L 10 40 Z" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>;
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 72 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0" y1="16" x2="10" y2="16" stroke="#555" strokeWidth="2"/>
|
||||
{!isNot && <line x1="0" y1="32" x2="10" y2="32" stroke="#555" strokeWidth="2"/>}
|
||||
{body}
|
||||
{exclusive && <path d="M 4 8 Q 14 24 4 40" fill="none" stroke="#2a2a2a" strokeWidth="1.5"/>}
|
||||
{negated && <circle cx={isNot ? 50 : 58} cy="24" r="3" fill="#f8f4ee" stroke="#2a2a2a" strokeWidth="1.5"/>}
|
||||
<line x1={negated ? (isNot ? 53 : 61) : (isNot ? 46 : 54)} y1="24" x2="72" y2="24" stroke="#555" strokeWidth="2"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const GateAnd: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('and', w, h);
|
||||
const GateNand: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('nand', w, h);
|
||||
const GateOr: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('or', w, h);
|
||||
const GateNor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('nor', w, h);
|
||||
const GateXor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('xor', w, h);
|
||||
const GateXnor: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('xnor', w, h);
|
||||
const GateNot: React.FC<InlineSVGProps> = ({ w, h }) => gateShape('not', w, h);
|
||||
|
||||
// ─── Power / instruments ───────────────────────────────────────────────────
|
||||
function reg3pinGlyph(label: string, w: number, h: number): React.ReactElement {
|
||||
return (
|
||||
<svg width={w} height={h} viewBox="0 0 72 56" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="8" width="56" height="40" rx="4" fill="#2a2a2a" stroke="#111" strokeWidth="1.5"/>
|
||||
<line x1="20" y1="48" x2="20" y2="56" stroke="#888" strokeWidth="2"/>
|
||||
<line x1="36" y1="48" x2="36" y2="56" stroke="#888" strokeWidth="2"/>
|
||||
<line x1="52" y1="48" x2="52" y2="56" stroke="#888" strokeWidth="2"/>
|
||||
<text x="36" y="32" textAnchor="middle" fontSize="10" fill="#eee" fontWeight="bold">{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const Reg7805: React.FC<InlineSVGProps> = ({ w, h }) => reg3pinGlyph('7805', w, h);
|
||||
const RegLM317: React.FC<InlineSVGProps> = ({ w, h }) => reg3pinGlyph('LM317', w, h);
|
||||
|
||||
const Battery9V: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 48 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="12" width="36" height="52" rx="3" fill="#c9a23a" stroke="#7a5e00" strokeWidth="1.5"/>
|
||||
<rect x="14" y="4" width="8" height="10" rx="1" fill="#ddd" stroke="#777" strokeWidth="1"/>
|
||||
<rect x="26" y="4" width="8" height="10" rx="1" fill="#ddd" stroke="#777" strokeWidth="1"/>
|
||||
<text x="24" y="40" textAnchor="middle" fontSize="12" fill="#3a2d00" fontWeight="bold">9V</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SignalGenerator: React.FC<InlineSVGProps> = ({ w, h }) => (
|
||||
<svg width={w} height={h} viewBox="0 0 80 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="72" height="56" rx="5" fill="#1f2937" stroke="#eef3fa" strokeWidth="1.2"/>
|
||||
<rect x="10" y="10" width="60" height="20" rx="2" fill="#061018" stroke="#0e8c70" strokeWidth="1"/>
|
||||
<path d="M 14 20 Q 22 12 30 20 T 46 20 T 62 20" fill="none" stroke="#4ade80" strokeWidth="1.5"/>
|
||||
<circle cx="20" cy="46" r="5" fill="#374151" stroke="#9ca3af" strokeWidth="1"/>
|
||||
<text x="56" y="50" textAnchor="middle" fontSize="7" fill="#e5e7eb">SIG GEN</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ─── Registry ──────────────────────────────────────────────────────────────
|
||||
interface InlineEntry {
|
||||
component: React.FC<InlineSVGProps>;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export const INLINE_SVGS: Record<string, InlineEntry> = {
|
||||
// BJTs
|
||||
'wokwi-bjt-2n2222': { component: BjtNpn, w: 72, h: 72 },
|
||||
'wokwi-bjt-2n3904': { component: BjtNpn, w: 72, h: 72 },
|
||||
'wokwi-bjt-2n3906': { component: BjtPnp, w: 72, h: 72 },
|
||||
'wokwi-bjt-bc547': { component: BjtNpn, w: 72, h: 72 },
|
||||
// MOSFETs
|
||||
'wokwi-mosfet-2n7000': { component: Mosfet, w: 72, h: 72 },
|
||||
'wokwi-mosfet-irf540n': { component: Mosfet, w: 72, h: 72 },
|
||||
'wokwi-mosfet-bs170': { component: Mosfet, w: 72, h: 72 },
|
||||
// Diodes
|
||||
'wokwi-diode': { component: Diode1N4007, w: 72, h: 40 },
|
||||
'wokwi-diode-1n4007': { component: Diode1N4007, w: 72, h: 40 },
|
||||
'wokwi-diode-1n4148': { component: Diode1N4007, w: 72, h: 40 },
|
||||
'wokwi-diode-1n5817': { component: Diode1N5817, w: 72, h: 40 },
|
||||
'wokwi-diode-1n5819': { component: Diode1N5817, w: 72, h: 40 },
|
||||
'wokwi-zener-1n4733': { component: DiodeZener, w: 72, h: 40 },
|
||||
// Passives
|
||||
'wokwi-capacitor': { component: Capacitor, w: 56, h: 36 },
|
||||
'wokwi-inductor': { component: Capacitor, w: 56, h: 36 },
|
||||
// Electromechanical
|
||||
'wokwi-relay': { component: Relay, w: 96, h: 96 },
|
||||
// Optocouplers
|
||||
'wokwi-opto-4n25': { component: Opto4N25, w: 80, h: 64 },
|
||||
'wokwi-opto-pc817': { component: Opto4N25, w: 80, h: 64 },
|
||||
// IC
|
||||
'wokwi-motor-driver-l293d': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc00': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc04': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc08': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc14': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc32': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
'wokwi-ic-74hc86': { component: MotorDriverL293D, w: 100, h: 80 },
|
||||
// Op-amps
|
||||
'wokwi-opamp-ideal': { component: OpampLM358, w: 80, h: 72 },
|
||||
'wokwi-opamp-lm358': { component: OpampLM358, w: 80, h: 72 },
|
||||
'wokwi-opamp-lm741': { component: OpampLM358, w: 80, h: 72 },
|
||||
'wokwi-opamp-lm324': { component: OpampLM358, w: 80, h: 72 },
|
||||
'wokwi-opamp-tl072': { component: OpampLM358, w: 80, h: 72 },
|
||||
// Logic gates — examples use both `wokwi-logic-gate-*` and `wokwi-logic-*` naming
|
||||
'wokwi-logic-gate-and': { component: GateAnd, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-or': { component: GateOr, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-nand': { component: GateNand, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-nor': { component: GateNor, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-xor': { component: GateXor, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-xnor': { component: GateXnor, w: 72, h: 48 },
|
||||
'wokwi-logic-gate-not': { component: GateNot, w: 72, h: 48 },
|
||||
'wokwi-logic-and': { component: GateAnd, w: 72, h: 48 },
|
||||
'wokwi-logic-or': { component: GateOr, w: 72, h: 48 },
|
||||
'wokwi-logic-nand': { component: GateNand, w: 72, h: 48 },
|
||||
'wokwi-logic-nor': { component: GateNor, w: 72, h: 48 },
|
||||
'wokwi-logic-xor': { component: GateXor, w: 72, h: 48 },
|
||||
'wokwi-logic-xnor': { component: GateXnor, w: 72, h: 48 },
|
||||
'wokwi-logic-not': { component: GateNot, w: 72, h: 48 },
|
||||
// Power
|
||||
'wokwi-reg-7805': { component: Reg7805, w: 72, h: 56 },
|
||||
'wokwi-reg-7812': { component: Reg7805, w: 72, h: 56 },
|
||||
'wokwi-reg-7905': { component: Reg7805, w: 72, h: 56 },
|
||||
'wokwi-reg-lm317': { component: RegLM317, w: 72, h: 56 },
|
||||
'wokwi-battery-9v': { component: Battery9V, w: 48, h: 72 },
|
||||
'wokwi-battery-aa': { component: Battery9V, w: 48, h: 72 },
|
||||
'wokwi-signal-generator': { component: SignalGenerator, w: 80, h: 64 },
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ import type { SegmentHandle } from './WireLayer';
|
|||
import { ElectricalOverlay } from '../analog-ui/ElectricalOverlay';
|
||||
import { BoardOnCanvas } from './BoardOnCanvas';
|
||||
import { PartSimulationRegistry } from '../../simulation/parts';
|
||||
import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils';
|
||||
import { isSpiceMapped } from '../../simulation/spice/componentToSpice';
|
||||
import { PinOverlay } from './PinOverlay';
|
||||
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
|
||||
|
|
@ -221,6 +222,27 @@ export const SimulatorCanvas = () => {
|
|||
initSimulator();
|
||||
}, [initSimulator]);
|
||||
|
||||
// Runtime parts (pots, switches, sensor panels) emit
|
||||
// `velxio:property-change` instead of writing the store directly — one
|
||||
// listener here routes every mutation through `updateComponent()`, which
|
||||
// is the same path the Property Dialog uses. Keeps parts decoupled from
|
||||
// Zustand and guarantees the SPICE netlist memo invalidates on every
|
||||
// user-driven property change.
|
||||
useEffect(() => {
|
||||
const onPropertyChange = (evt: Event) => {
|
||||
const { componentId, propName, value } = (evt as CustomEvent<PropertyChangeDetail>).detail;
|
||||
const state = useSimulatorStore.getState();
|
||||
const comp = state.components.find((c) => c.id === componentId);
|
||||
if (!comp) return;
|
||||
if (String(comp.properties?.[propName]) === String(value)) return;
|
||||
state.updateComponent(componentId, {
|
||||
properties: { ...comp.properties, [propName]: value },
|
||||
});
|
||||
};
|
||||
window.addEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange);
|
||||
return () => window.removeEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange);
|
||||
}, []);
|
||||
|
||||
// Auto-start/stop Pi bridges when simulation state changes
|
||||
const startBoard = useSimulatorStore((s) => s.startBoard);
|
||||
const stopBoard = useSimulatorStore((s) => s.stopBoard);
|
||||
|
|
@ -857,17 +879,23 @@ export const SimulatorCanvas = () => {
|
|||
|
||||
// Handle component selection from modal
|
||||
const handleSelectComponent = (metadata: ComponentMetadata) => {
|
||||
// Calculate grid position to avoid overlapping
|
||||
// Use existing components count to determine position
|
||||
const componentsCount = components.length;
|
||||
const gridSize = 250; // Space between components
|
||||
const cols = 3; // Components per row
|
||||
// Anchor new components to the visible top-left of the canvas, so they
|
||||
// appear in the user's current viewport regardless of pan/zoom (instead
|
||||
// of growing off-screen at fixed world coords like (400, 100 + row*250)).
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
const z = zoomRef.current || 1;
|
||||
const screenMargin = 60; // px on screen — keeps the part off the toolbar/edge
|
||||
const worldOrigin = rect
|
||||
? toWorld(rect.left + screenMargin, rect.top + screenMargin)
|
||||
: { x: 100, y: 100 };
|
||||
|
||||
const col = componentsCount % cols;
|
||||
const row = Math.floor(componentsCount / cols);
|
||||
|
||||
const x = 400 + (col * gridSize);
|
||||
const y = 100 + (row * gridSize);
|
||||
// Tile additional drops so they don't stack exactly on top of each other,
|
||||
// while still landing inside the viewport.
|
||||
const tileStep = 40 / z; // 40 screen-px between successive drops
|
||||
const cols = 4;
|
||||
const idx = components.length;
|
||||
const x = worldOrigin.x + (idx % cols) * tileStep;
|
||||
const y = worldOrigin.y + Math.floor(idx / cols) * tileStep;
|
||||
|
||||
const component = createComponentFromMetadata(metadata, x, y);
|
||||
trackAddComponent(metadata.id);
|
||||
|
|
|
|||
|
|
@ -934,11 +934,17 @@ void loop() {
|
|||
description: 'Sum = A XOR B XOR Cin, Cout = (A AND B) OR (Cin AND (A XOR B)).',
|
||||
category: 'circuits', difficulty: 'intermediate',
|
||||
code: `// 1-bit full adder in software
|
||||
void setup() { Serial.begin(9600); pinMode(2,INPUT_PULLUP); pinMode(3,INPUT_PULLUP); pinMode(4,INPUT_PULLUP); }
|
||||
void setup() {
|
||||
Serial.begin(9600);
|
||||
pinMode(2,INPUT_PULLUP); pinMode(3,INPUT_PULLUP); pinMode(4,INPUT_PULLUP);
|
||||
pinMode(5,OUTPUT); pinMode(6,OUTPUT);
|
||||
}
|
||||
void loop() {
|
||||
bool a=!digitalRead(2), b=!digitalRead(3), cin=!digitalRead(4);
|
||||
bool sum = a ^ b ^ cin;
|
||||
bool cout = (a&b) | (cin&(a^b));
|
||||
digitalWrite(5, sum);
|
||||
digitalWrite(6, cout);
|
||||
Serial.print("A="); Serial.print(a); Serial.print(" B="); Serial.print(b);
|
||||
Serial.print(" Cin="); Serial.print(cin); Serial.print(" Sum="); Serial.print(sum);
|
||||
Serial.print(" Cout="); Serial.println(cout);
|
||||
|
|
@ -949,8 +955,10 @@ void loop() {
|
|||
{ type: 'wokwi-pushbutton', id: 'bA', x: 350, y: 60, properties: {} },
|
||||
{ type: 'wokwi-pushbutton', id: 'bB', x: 350, y: 140, properties: {} },
|
||||
{ type: 'wokwi-pushbutton', id: 'bCin', x: 350, y: 220, properties: {} },
|
||||
{ type: 'wokwi-led', id: 'sumLed', x: 480, y: 100, properties: { color: 'green' } },
|
||||
{ type: 'wokwi-led', id: 'coutLed', x: 480, y: 200, properties: { color: 'red' } },
|
||||
{ type: 'wokwi-resistor', id: 'rSum', x: 440, y: 100, properties: { value: '220' } },
|
||||
{ type: 'wokwi-resistor', id: 'rCout', x: 440, y: 200, properties: { value: '220' } },
|
||||
{ type: 'wokwi-led', id: 'sumLed', x: 540, y: 100, properties: { color: 'green' } },
|
||||
{ type: 'wokwi-led', id: 'coutLed', x: 540, y: 200, properties: { color: 'red' } },
|
||||
],
|
||||
wires: [
|
||||
w('w1', ['arduino-uno','2'], ['bA','1.l']),
|
||||
|
|
@ -959,6 +967,14 @@ void loop() {
|
|||
w('w4', ['bB','2.l'], ['arduino-uno','GND'], '#000000'),
|
||||
w('w5', ['arduino-uno','4'], ['bCin','1.l']),
|
||||
w('w6', ['bCin','2.l'], ['arduino-uno','GND'], '#000000'),
|
||||
// Sum LED: pin 5 → 220Ω → LED → GND
|
||||
w('w7', ['arduino-uno','5'], ['rSum','1']),
|
||||
w('w8', ['rSum','2'], ['sumLed','A']),
|
||||
w('w9', ['sumLed','C'], ['arduino-uno','GND'], '#000000'),
|
||||
// Cout LED: pin 6 → 220Ω → LED → GND
|
||||
w('w10', ['arduino-uno','6'], ['rCout','1']),
|
||||
w('w11', ['rCout','2'], ['coutLed','A']),
|
||||
w('w12', ['coutLed','C'], ['arduino-uno','GND'], '#000000'),
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PartSimulationRegistry } from './PartSimulationRegistry';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { syncStoreProperty } from './partUtils';
|
||||
import { emitPropertyChange } from './partUtils';
|
||||
|
||||
/**
|
||||
* Basic Pushbutton implementation (full-size)
|
||||
|
|
@ -14,12 +14,12 @@ PartSimulationRegistry.register('pushbutton', {
|
|||
const onButtonPress = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); // Active LOW
|
||||
(element as any).pressed = true;
|
||||
syncStoreProperty(componentId, 'pressed', true);
|
||||
emitPropertyChange(componentId, 'pressed', true);
|
||||
};
|
||||
const onButtonRelease = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
|
||||
(element as any).pressed = false;
|
||||
syncStoreProperty(componentId, 'pressed', false);
|
||||
emitPropertyChange(componentId, 'pressed', false);
|
||||
};
|
||||
|
||||
element.addEventListener('button-press', onButtonPress);
|
||||
|
|
@ -43,12 +43,12 @@ PartSimulationRegistry.register('pushbutton-6mm', {
|
|||
const onPress = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false);
|
||||
(element as any).pressed = true;
|
||||
syncStoreProperty(componentId, 'pressed', true);
|
||||
emitPropertyChange(componentId, 'pressed', true);
|
||||
};
|
||||
const onRelease = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
|
||||
(element as any).pressed = false;
|
||||
syncStoreProperty(componentId, 'pressed', false);
|
||||
emitPropertyChange(componentId, 'pressed', false);
|
||||
};
|
||||
|
||||
element.addEventListener('button-press', onPress);
|
||||
|
|
@ -72,13 +72,13 @@ PartSimulationRegistry.register('slide-switch', {
|
|||
const raw = (element as any).value;
|
||||
let state = raw === 1 || raw === '1';
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state);
|
||||
syncStoreProperty(componentId, 'value', state ? 1 : 0);
|
||||
emitPropertyChange(componentId, 'value', state ? 1 : 0);
|
||||
|
||||
const onChange = () => {
|
||||
const v = (element as any).value;
|
||||
state = v === 1 || v === '1';
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, state);
|
||||
syncStoreProperty(componentId, 'value', state ? 1 : 0);
|
||||
emitPropertyChange(componentId, 'value', state ? 1 : 0);
|
||||
};
|
||||
|
||||
element.addEventListener('change', onChange);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { PartSimulationRegistry } from './PartSimulationRegistry';
|
||||
import type { AnySimulator } from './PartSimulationRegistry';
|
||||
import { RP2040Simulator } from '../RP2040Simulator';
|
||||
import { getADC, setAdcVoltage, syncStoreProperty } from './partUtils';
|
||||
import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils';
|
||||
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -79,7 +79,7 @@ PartSimulationRegistry.register('potentiometer', {
|
|||
}
|
||||
// Mirror to store so the SPICE netlist re-solves (op-amp
|
||||
// comparators, divider-driven circuits etc. depend on this).
|
||||
syncStoreProperty(componentId, 'value', raw);
|
||||
emitPropertyChange(componentId, 'value', raw);
|
||||
};
|
||||
|
||||
onInput();
|
||||
|
|
@ -109,7 +109,7 @@ PartSimulationRegistry.register('slide-potentiometer', {
|
|||
const volts = normalized * refVoltage;
|
||||
setAdcVoltage(avrSimulator, arduinoPin, volts);
|
||||
}
|
||||
syncStoreProperty(componentId, 'value', value);
|
||||
emitPropertyChange(componentId, 'value', value);
|
||||
};
|
||||
|
||||
onInput();
|
||||
|
|
@ -153,7 +153,7 @@ PartSimulationRegistry.register('photoresistor-sensor', {
|
|||
}
|
||||
// Mirror to store — maps the slider 0-1023 back to lux 0-1000
|
||||
// so the SPICE photoresistor handler re-computes its R_ldr.
|
||||
syncStoreProperty(componentId, 'lux', Math.round((val / 1023) * 1000));
|
||||
emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000));
|
||||
}
|
||||
};
|
||||
element.addEventListener('input', onInput);
|
||||
|
|
@ -172,7 +172,7 @@ PartSimulationRegistry.register('photoresistor-sensor', {
|
|||
if (pinAO !== null) {
|
||||
setAdcVoltage(avrSimulator, pinAO, ((values.lux as number) / 1000) * 5.0);
|
||||
}
|
||||
syncStoreProperty(componentId, 'lux', values.lux);
|
||||
emitPropertyChange(componentId, 'lux', values.lux);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import { PartSimulationRegistry } from './PartSimulationRegistry';
|
||||
import { setAdcVoltage, syncStoreProperty } from './partUtils';
|
||||
import { setAdcVoltage, emitPropertyChange } from './partUtils';
|
||||
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
|
||||
|
||||
// ─── Tilt Switch ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -89,7 +89,7 @@ PartSimulationRegistry.register('ntc-temperature-sensor', {
|
|||
}
|
||||
// Mirror to store — the SPICE ntc-temperature-sensor handler
|
||||
// reads comp.properties.temperature when computing R_ntc.
|
||||
syncStoreProperty(componentId, 'temperature', values.temperature);
|
||||
emitPropertyChange(componentId, 'temperature', values.temperature);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,27 +7,28 @@
|
|||
|
||||
import type { AnySimulator } from './PartSimulationRegistry';
|
||||
import { RP2040Simulator } from '../RP2040Simulator';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
|
||||
/** DOM event fired when a runtime part mutates a user-facing property. */
|
||||
export const PROPERTY_CHANGE_EVENT = 'velxio:property-change';
|
||||
|
||||
export interface PropertyChangeDetail {
|
||||
componentId: string;
|
||||
propName: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a live DOM / sensor-panel value into the component's store properties
|
||||
* so SPICE's netlist memo invalidates and the next `maybeSolve()` picks up the
|
||||
* change. Without this, dragging a potentiometer, pressing a button, or moving
|
||||
* a sensor slider updates the ADC but leaves the SPICE netlist stale, so any
|
||||
* analog circuit driven by the input (comparators, op-amp networks, divider
|
||||
* bridges) freezes at the first `.op` solve.
|
||||
*
|
||||
* Idempotent — no-op when the value hasn't changed since the previous sync.
|
||||
* Dispatch a property change so the canvas can route it through
|
||||
* `updateComponent()`. Parts call this whenever a DOM / sensor-panel value
|
||||
* mutates so the SPICE netlist memo invalidates and the next `maybeSolve()`
|
||||
* picks up the change. Parts stay decoupled from Zustand — `SimulatorCanvas`
|
||||
* is the single listener that applies the update.
|
||||
*/
|
||||
export function syncStoreProperty(componentId: string, propName: string, value: unknown): void {
|
||||
const store = useSimulatorStore.getState();
|
||||
const comp = store.components.find((c) => c.id === componentId);
|
||||
if (!comp) return;
|
||||
const prev = comp.properties?.[propName];
|
||||
if (String(prev) === String(value)) return;
|
||||
store.updateComponent(componentId, {
|
||||
properties: { ...comp.properties, [propName]: value },
|
||||
});
|
||||
export function emitPropertyChange(componentId: string, propName: string, value: unknown): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
|
||||
const detail: PropertyChangeDetail = { componentId, propName, value };
|
||||
window.dispatchEvent(new CustomEvent(PROPERTY_CHANGE_EVENT, { detail }));
|
||||
}
|
||||
|
||||
/** Read the ADC instance from the simulator (returns null if not initialized) */
|
||||
|
|
|
|||
|
|
@ -941,29 +941,43 @@ const MAPPERS: Record<string, Mapper> = {
|
|||
// uses a B-source that inverts the coil voltage as its control signal,
|
||||
// because ngspice SW has no "normally closed" mode.
|
||||
// Optional flyback diode across the coil (anode on COIL-, cathode on COIL+).
|
||||
// NO and NC contact cards are only emitted when their respective pins are
|
||||
// wired — leaving NC unconnected is a very common pattern and must not
|
||||
// suppress the rest of the relay (coil + NO switch).
|
||||
relay: (comp, netLookup) => {
|
||||
const cp = netLookup('COIL+');
|
||||
const cn = netLookup('COIL-');
|
||||
const com = netLookup('COM');
|
||||
const no = netLookup('NO');
|
||||
const nc = netLookup('NC');
|
||||
if (!cp || !cn || !com || !no || !nc) return null;
|
||||
// Coil pins must be present — without them the relay can't be energised.
|
||||
// COM is required too; without it, neither NO nor NC contact is useful.
|
||||
if (!cp || !cn || !com) return null;
|
||||
const coilR = Number(comp.properties.coil_resistance ?? 70);
|
||||
const coilV = Number(comp.properties.coil_voltage ?? 5);
|
||||
const threshold = coilV * 0.6; // drop-in at 60% of nominal
|
||||
const hysteresis = coilV * 0.15;
|
||||
const includeFlyback = comp.properties.include_flyback !== false;
|
||||
const ctrlInvNet = `${comp.id}_ncctrl`;
|
||||
// A relay coil is a wire-wound inductor: R (of the copper) in SERIES
|
||||
// with ideal L. Modelling R and L in parallel would make the coil a DC
|
||||
// short — V(COIL+) ≡ V(COIL-) in .op analysis — so the switch control
|
||||
// voltage is always 0 and the NO contact never closes.
|
||||
const coilMidNet = `${comp.id}_coilmid`;
|
||||
const cards = [
|
||||
`R_${comp.id}_coil ${cp} ${cn} ${coilR}`,
|
||||
`L_${comp.id}_coil ${cp} ${cn} 20m`,
|
||||
`R_${comp.id}_coil ${cp} ${coilMidNet} ${coilR}`,
|
||||
`L_${comp.id}_coil ${coilMidNet} ${cn} 20m`,
|
||||
];
|
||||
if (no) {
|
||||
// NO: closes when V_coil > Vt (normal SW behaviour)
|
||||
`S_${comp.id}_no ${com} ${no} ${cp} ${cn} RELAY_SW`,
|
||||
cards.push(`S_${comp.id}_no ${com} ${no} ${cp} ${cn} RELAY_SW`);
|
||||
}
|
||||
if (nc) {
|
||||
// NC: inverted control — B-source maps (V_coil → Vnom − V_coil) so that
|
||||
// SW still "turns on when ctrl > Vt", but meaning is inverted.
|
||||
`B_${comp.id}_ncctrl ${ctrlInvNet} 0 V = ${coilV} - (V(${cp}) - V(${cn}))`,
|
||||
`S_${comp.id}_nc ${com} ${nc} ${ctrlInvNet} 0 RELAY_SW`,
|
||||
];
|
||||
const ctrlInvNet = `${comp.id}_ncctrl`;
|
||||
cards.push(`B_${comp.id}_ncctrl ${ctrlInvNet} 0 V = ${coilV} - (V(${cp}) - V(${cn}))`);
|
||||
cards.push(`S_${comp.id}_nc ${com} ${nc} ${ctrlInvNet} 0 RELAY_SW`);
|
||||
}
|
||||
if (includeFlyback) {
|
||||
cards.push(`D_${comp.id}_fly ${cn} ${cp} D1N4148`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue