feat: Enhance simulator with new components and event handling

- Updated components-metadata.json with new generation timestamp.
- Added event handling for button presses and releases in DynamicComponent.
- Improved ExamplesGallery with new styles for placeholders and previews.
- Introduced LCD 20x4 display example with corresponding code and wiring.
- Enhanced SimulatorCanvas to subscribe components to pin changes.
- Implemented PartSimulationRegistry for managing component simulation logic.
- Added basic and complex parts simulation including pushbuttons, LEDs, and LCDs.
- Created utility functions for capturing canvas previews and generating SVG previews for example projects.
pull/10/head
David Montero Crespo 2026-03-04 13:36:33 -03:00
parent cf95aeacd8
commit ef7e86bc1e
15 changed files with 968 additions and 21 deletions

View File

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generatedAt": "2026-03-04T00:10:03.051Z",
"generatedAt": "2026-03-04T16:36:16.155Z",
"components": [
{
"id": "arduino-mega",

View File

@ -13,6 +13,8 @@
import React, { useRef, useEffect, useCallback } from 'react';
import type { ComponentMetadata } from '../types/component-metadata';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { PartSimulationRegistry } from '../simulation/parts';
interface DynamicComponentProps {
id: string;
@ -45,6 +47,8 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(false);
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
/**
* Sync React properties to Web Component
*/
@ -160,6 +164,57 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
// 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]); // Fallback if id is like led-1
let cleanupSimulationEvents: (() => void) | undefined;
if (logic && logic.attachEvents) {
// We need AVRSimulator instance. We can grab it from store.
const simulator = useSimulatorStore.getState().simulator;
if (simulator) {
// Helper to find Arduino pin connected to a component pin
const getArduinoPin = (componentPinName: string): number | null => {
const wires = useSimulatorStore.getState().wires.filter(
w => (w.start.componentId === id && w.start.pinName === componentPinName) ||
(w.end.componentId === id && w.end.pinName === componentPinName)
);
for (const w of wires) {
const arduinoEndpoint = w.start.componentId === 'arduino-uno' ? w.start :
w.end.componentId === 'arduino-uno' ? w.end : null;
if (arduinoEndpoint) {
const pin = parseInt(arduinoEndpoint.pinName, 10);
if (!isNaN(pin)) return pin;
}
}
return null;
};
cleanupSimulationEvents = logic.attachEvents(el, simulator, getArduinoPin);
}
}
return () => {
if (cleanupSimulationEvents) cleanupSimulationEvents();
// Old hardcoded events (to be removed in future if Pushbutton registry works fully)
el.removeEventListener('button-press', onButtonPress);
el.removeEventListener('button-release', onButtonRelease);
};
}, [id, handleComponentEvent, metadata.id]);
return (
<div
className="dynamic-component-wrapper"

View File

@ -139,7 +139,11 @@
.example-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
.example-preview-image {
background-color: #1a1a1a;
}
.example-placeholder {
@ -152,6 +156,46 @@
height: 100%;
}
.example-placeholder-new {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #2d2d2d 0%, #1e1e1e 100%);
border: 2px dashed #444;
}
.placeholder-icon {
font-size: 72px;
opacity: 0.7;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
.placeholder-text {
display: flex;
flex-direction: column;
gap: 6px;
text-align: center;
}
.component-count,
.wire-count {
font-size: 13px;
color: #aaa;
font-weight: 500;
}
.component-count {
color: #00d9ff;
}
.wire-count {
color: #fbbf24;
}
.example-icon {
font-size: 64px;
opacity: 0.5;

View File

@ -128,13 +128,18 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
>
<div className="example-thumbnail">
{example.thumbnail ? (
<img src={example.thumbnail} alt={example.title} />
<img src={example.thumbnail} alt={example.title} className="example-preview-image" />
) : (
<div className="example-placeholder">
<span className="example-icon">{getCategoryIcon(example.category)}</span>
<span className="example-components-count">
{example.components.length} component{example.components.length !== 1 ? 's' : ''}
</span>
<div className="example-placeholder-new">
<div className="placeholder-icon">{getCategoryIcon(example.category)}</div>
<div className="placeholder-text">
<div className="component-count">
{example.components.length} component{example.components.length !== 1 ? 's' : ''}
</div>
<div className="wire-count">
{example.wires.length} wire{example.wires.length !== 1 ? 's' : ''}
</div>
</div>
</div>
)}
</div>

View File

@ -8,6 +8,7 @@ import { ComponentRegistry } from '../../services/ComponentRegistry';
import { PinSelector } from './PinSelector';
import { WireLayer } from './WireLayer';
import { PinOverlay } from './PinOverlay';
import { PartSimulationRegistry } from '../../simulation/parts';
import type { ComponentMetadata } from '../../types/component-metadata';
import './SimulatorCanvas.css';
@ -73,17 +74,51 @@ export const SimulatorCanvas = () => {
useEffect(() => {
const unsubscribers: (() => void)[] = [];
components.forEach((component) => {
if (component.properties.pin !== undefined) {
const unsubscribe = pinManager.onPinChange(
component.properties.pin,
(pin, state) => {
// Update component state when pin changes
updateComponentState(component.id, state);
console.log(`Component ${component.id} on pin ${pin}: ${state ? 'HIGH' : 'LOW'}`);
// Helper to add subscription
const subscribeComponentToPin = (component: any, pin: number, componentPinName?: string) => {
const unsubscribe = pinManager.onPinChange(
pin,
(_pin, state) => {
// 1. Update React state for standard properties
updateComponentState(component.id, state);
// 2. Delegate to PartSimulationRegistry for custom visual updates
const logic = PartSimulationRegistry.get(component.metadataId);
if (logic && logic.onPinStateChange) {
const el = document.getElementById(component.id);
if (el) {
logic.onPinStateChange(componentPinName || 'A', state, el);
}
}
console.log(`Component ${component.id} on pin ${pin}: ${state ? 'HIGH' : 'LOW'}`);
}
);
unsubscribers.push(unsubscribe);
};
components.forEach((component) => {
// 1. Subscribe by explicit pin property
if (component.properties.pin !== undefined) {
subscribeComponentToPin(component, component.properties.pin as number, 'A');
} else {
// 2. Subscribe by finding wires connected to arduino
const connectedWires = useSimulatorStore.getState().wires.filter(
w => w.start.componentId === component.id || w.end.componentId === component.id
);
unsubscribers.push(unsubscribe);
connectedWires.forEach(wire => {
const isStartSelf = wire.start.componentId === component.id;
const selfEndpoint = isStartSelf ? wire.start : wire.end;
const otherEndpoint = isStartSelf ? wire.end : wire.start;
if (otherEndpoint.componentId === 'arduino-uno') {
const pin = parseInt(otherEndpoint.pinName, 10);
if (!isNaN(pin)) {
subscribeComponentToPin(component, pin, selfEndpoint.pinName);
}
}
});
}
});
@ -344,7 +379,7 @@ export const SimulatorCanvas = () => {
<ArduinoUno
x={ARDUINO_POSITION.x}
y={ARDUINO_POSITION.y}
led13={components.find((c) => c.id === 'led-builtin')?.properties.state || false}
led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
{/* Arduino pin overlay */}

View File

@ -593,6 +593,67 @@ void loop() {
},
],
},
{
id: 'lcd-hello',
title: 'LCD 20x4 Display',
description: 'Display text on a 20x4 LCD using the LiquidCrystal library',
category: 'displays',
difficulty: 'intermediate',
code: `// LiquidCrystal Library - Hello World
// Demonstrates the use a 20x4 LCD display
#include <LiquidCrystal.h>
// initialize the library by associating any needed LCD interface pin
// with the arduino pin number it is connected to
const int rs = 12, en = 11, d4 = 5, d5 = 4, d6 = 3, d7 = 2;
LiquidCrystal lcd(rs, en, d4, d5, d6, d7);
void setup() {
// set up the LCD's number of columns and rows:
lcd.begin(20, 4);
// Print a message to the LCD.
lcd.print("Hello, Arduino!");
lcd.setCursor(0, 1);
lcd.print("Wokwi Emulator");
lcd.setCursor(0, 2);
lcd.print("LCD 2004 Test");
}
void loop() {
// set the cursor to column 0, line 3
lcd.setCursor(0, 3);
// print the number of seconds since reset:
lcd.print("Uptime: ");
lcd.print(millis() / 1000);
}
`,
components: [
{
type: 'wokwi-arduino-uno',
id: 'arduino-uno',
x: 100,
y: 100,
properties: {},
},
{
type: 'wokwi-lcd2004',
id: 'lcd1',
x: 450,
y: 100,
properties: { pins: 'full' },
},
],
wires: [
{ id: 'w-rs', start: { componentId: 'arduino-uno', pinName: '12' }, end: { componentId: 'lcd1', pinName: 'RS' }, color: 'green' },
{ id: 'w-en', start: { componentId: 'arduino-uno', pinName: '11' }, end: { componentId: 'lcd1', pinName: 'E' }, color: 'green' },
{ id: 'w-d4', start: { componentId: 'arduino-uno', pinName: '5' }, end: { componentId: 'lcd1', pinName: 'D4' }, color: 'blue' },
{ id: 'w-d5', start: { componentId: 'arduino-uno', pinName: '4' }, end: { componentId: 'lcd1', pinName: 'D5' }, color: 'blue' },
{ id: 'w-d6', start: { componentId: 'arduino-uno', pinName: '3' }, end: { componentId: 'lcd1', pinName: 'D6' }, color: 'blue' },
{ id: 'w-d7', start: { componentId: 'arduino-uno', pinName: '2' }, end: { componentId: 'lcd1', pinName: 'D7' }, color: 'blue' },
// Power / Contrast logic is usually handled internally or ignored in basic simulation
],
},
];
// Get examples by category

View File

@ -22,10 +22,15 @@ export const ExamplesPage: React.FC = () => {
// Load the code into the editor
setCode(example.code);
// Filter out Arduino component from examples (it's rendered separately in SimulatorCanvas)
const componentsWithoutArduino = example.components.filter(
(comp) => !comp.type.includes('arduino')
);
// Load components into the simulator
// Convert component type to metadataId (e.g., 'wokwi-led' -> 'led')
setComponents(
example.components.map((comp) => ({
componentsWithoutArduino.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace('wokwi-', ''),
x: comp.x,

View File

@ -22,7 +22,7 @@ export class AVRSimulator {
private program: Uint16Array | null = null;
private running = false;
private animationFrame: number | null = null;
private pinManager: PinManager;
public pinManager: PinManager;
private speed = 1.0; // Simulation speed multiplier
private lastPortBValue = 0;
private lastPortCValue = 0;
@ -247,4 +247,17 @@ export class AVRSimulator {
avrInstruction(this.cpu); // Execute the instruction
this.cpu.tick(); // Update peripherals
}
/**
* Set the state of an Arduino pin externally (e.g. from a UI button)
*/
setPinState(arduinoPin: number, state: boolean): void {
if (arduinoPin >= 0 && arduinoPin <= 7 && this.portD) {
this.portD.setPin(arduinoPin, state);
} else if (arduinoPin >= 8 && arduinoPin <= 13 && this.portB) {
this.portB.setPin(arduinoPin - 8, state);
} else if (arduinoPin >= 14 && arduinoPin <= 19 && this.portC) {
this.portC.setPin(arduinoPin - 14, state);
}
}
}

View File

@ -0,0 +1,50 @@
import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AVRSimulator } from '../AVRSimulator';
/**
* Basic Pushbutton implementation
*/
PartSimulationRegistry.register('pushbutton', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
// 1. Find which Arduino pin is connected to terminal '1.L' or '2.L'
const arduinoPin =
getArduinoPinHelper('1.l') ?? getArduinoPinHelper('2.l') ??
getArduinoPinHelper('1.r') ?? getArduinoPinHelper('2.r');
if (arduinoPin === null) {
return () => { }; // no-op if unconnected
}
const onButtonPress = () => {
// By default wokwi pushbuttons are active LOW (connected to GND)
avrSimulator.setPinState(arduinoPin, false);
(element as any).pressed = true;
};
const onButtonRelease = () => {
// Release lets the internal pull-up pull it HIGH
avrSimulator.setPinState(arduinoPin, true);
(element as any).pressed = false;
};
element.addEventListener('button-press', onButtonPress);
element.addEventListener('button-release', onButtonRelease);
return () => {
element.removeEventListener('button-press', onButtonPress);
element.removeEventListener('button-release', onButtonRelease);
};
},
});
/**
* Basic LED implementation
*/
PartSimulationRegistry.register('led', {
onPinStateChange: (pinName, state, element) => {
if (pinName === 'A') { // Anode
(element as any).value = state;
}
// We ignore cathode 'C' in this simple digital model
}
});

View File

@ -0,0 +1,309 @@
import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AVRSimulator } from '../AVRSimulator';
/**
* RGB LED implementation
* Translates digital HIGH/LOW to corresponding 255/0 values on RGB channels
*/
PartSimulationRegistry.register('rgb-led', {
onPinStateChange: (pinName: string, state: boolean, element: HTMLElement) => {
const el = element as any;
if (pinName === 'R') {
el.ledRed = state ? 255 : 0;
} else if (pinName === 'G') {
el.ledGreen = state ? 255 : 0;
} else if (pinName === 'B') {
el.ledBlue = state ? 255 : 0;
}
}
});
/**
* Analog Potentiometer implementation
*/
PartSimulationRegistry.register('potentiometer', {
attachEvents: (element: HTMLElement, avrSimulator: AVRSimulator, getArduinoPinHelper: (pin: string) => number | null) => {
// A potentiometer's 'SIG' pin goes to an Analog In (A0-A5).
// We map generic pin integers back to our ADC logic.
// E.g., A0 = pin 14 on UNO
// Potentiometer emits 'input' events when dragged
const onInput = (_e: Event) => {
const arduinoPin = getArduinoPinHelper('SIG');
// If connected to Analog Pin (14-19 is A0-A5 on Uno)
if (arduinoPin !== null && arduinoPin >= 14 && arduinoPin <= 19) {
// Find the analog channel (0-5)
const channel = arduinoPin - 14;
// Element's value is between 0-1023
const value = parseInt((element as any).value || '0', 10);
// Access avr8js ADC to inject analog voltages
// (Assuming getADC is implemented in AVRSimulator)
const adc: any = (avrSimulator as any).getADC?.();
if (adc) {
// ADC wants a float voltage from 0 to 5V.
// Potentiometer is linearly 0-1023 -> 5V max
const volts = (value / 1023.0) * 5.0;
adc.channelValues[channel] = volts;
}
}
};
element.addEventListener('input', onInput);
return () => {
element.removeEventListener('input', onInput);
};
},
});
/**
* HD44780 LCD Controller Simulation (for LCD 1602 and LCD 2004)
*
* Implements the 4-bit mode protocol used by the Arduino LiquidCrystal library.
* The HD44780 controller uses 6 signal lines in 4-bit mode:
* RS (Register Select): 0 = command, 1 = data
* E (Enable): Data is latched on falling edge (HIGHLOW)
* D4-D7: 4 data bits (high nibble first, then low nibble)
*
* DDRAM address mapping for multi-line displays:
* Line 0: 0x00-0x13 (or 0x00-0x0F for 16x2)
* Line 1: 0x40-0x53 (or 0x40-0x4F for 16x2)
* Line 2: 0x14-0x27 (20x4 only)
* Line 3: 0x54-0x67 (20x4 only)
*/
function createLcdSimulation(cols: number, rows: number) {
return {
attachEvents: (element: HTMLElement, avrSimulator: AVRSimulator, getArduinoPinHelper: (pin: string) => number | null) => {
const el = element as any;
// HD44780 internal state
const ddram = new Uint8Array(128).fill(0x20); // Display Data RAM (space = 0x20)
let ddramAddress = 0; // Current DDRAM address
let entryIncrement = true; // true = increment, false = decrement
let displayOn = true; // Is display on?
let cursorOn = false; // Underline cursor visible?
let blinkOn = false; // Blinking block cursor?
let nibbleState: 'high' | 'low' = 'high'; // 4-bit mode nibble tracking
let highNibble = 0; // Stored high nibble
let initialized = false; // Has initialization sequence completed?
let initCount = 0; // Count initialization nibbles
// Pin states tracked locally
let rsState = false;
let eState = false;
let d4State = false;
let d5State = false;
let d6State = false;
let d7State = false;
// DDRAM line offsets for the HD44780
const lineOffsets = rows >= 4
? [0x00, 0x40, 0x14, 0x54] // 20x4 LCD
: [0x00, 0x40]; // 16x2 LCD
// Convert DDRAM address to linear buffer index for the element
function ddramToLinear(addr: number): number {
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
if (addr >= offset && addr < offset + cols) {
return row * cols + (addr - offset);
}
}
return -1; // Address not visible
}
// Refresh the wokwi-element's characters from our DDRAM
function refreshDisplay() {
if (!displayOn) {
// Blank display
el.characters = new Uint8Array(cols * rows).fill(0x20);
return;
}
const chars = new Uint8Array(cols * rows);
for (let row = 0; row < rows; row++) {
const offset = lineOffsets[row];
for (let col = 0; col < cols; col++) {
chars[row * cols + col] = ddram[offset + col];
}
}
el.characters = chars;
el.cursor = cursorOn;
el.blink = blinkOn;
// Set cursor position
const cursorLinear = ddramToLinear(ddramAddress);
if (cursorLinear >= 0) {
el.cursorX = cursorLinear % cols;
el.cursorY = Math.floor(cursorLinear / cols);
}
}
// Process a complete byte (command or data)
function processByte(rs: boolean, data: number) {
if (!rs) {
// === COMMAND ===
if (data & 0x80) {
// Set DDRAM Address (bit 7 = 1)
ddramAddress = data & 0x7F;
} else if (data & 0x40) {
// Set CGRAM Address - not implemented for display
} else if (data & 0x20) {
// Function Set (usually during init)
// DL=0 means 4-bit mode (already assumed)
initialized = true;
} else if (data & 0x10) {
// Cursor/Display Shift
const sc = (data >> 3) & 1;
const rl = (data >> 2) & 1;
if (!sc) {
// Move cursor
ddramAddress += rl ? 1 : -1;
ddramAddress &= 0x7F;
}
} else if (data & 0x08) {
// Display On/Off Control
displayOn = !!(data & 0x04);
cursorOn = !!(data & 0x02);
blinkOn = !!(data & 0x01);
} else if (data & 0x04) {
// Entry Mode Set
entryIncrement = !!(data & 0x02);
} else if (data & 0x02) {
// Return Home
ddramAddress = 0;
} else if (data & 0x01) {
// Clear Display
ddram.fill(0x20);
ddramAddress = 0;
}
} else {
// === DATA (character write) ===
ddram[ddramAddress & 0x7F] = data;
// Auto-increment/decrement address
if (entryIncrement) {
ddramAddress = (ddramAddress + 1) & 0x7F;
} else {
ddramAddress = (ddramAddress - 1) & 0x7F;
}
}
refreshDisplay();
}
// Handle Enable pin falling edge - this is where data is latched
function onEnableFallingEdge() {
// Read D4-D7 to form a nibble
const nibble =
(d4State ? 0x01 : 0) |
(d5State ? 0x02 : 0) |
(d6State ? 0x04 : 0) |
(d7State ? 0x08 : 0);
// During initialization, the LiquidCrystal library sends
// several single-nibble commands (0x03, 0x03, 0x03, 0x02)
// before switching to 4-bit mode proper
if (!initialized) {
initCount++;
if (initCount >= 4) {
initialized = true;
nibbleState = 'high';
}
return;
}
if (nibbleState === 'high') {
// First nibble (high 4 bits)
highNibble = nibble << 4;
nibbleState = 'low';
} else {
// Second nibble (low 4 bits) → combine and process
const fullByte = highNibble | nibble;
nibbleState = 'high';
processByte(rsState, fullByte);
}
}
// Get Arduino pin numbers for LCD pins
const pinRS = getArduinoPinHelper('RS');
const pinE = getArduinoPinHelper('E');
const pinD4 = getArduinoPinHelper('D4');
const pinD5 = getArduinoPinHelper('D5');
const pinD6 = getArduinoPinHelper('D6');
const pinD7 = getArduinoPinHelper('D7');
console.log(`[LCD] Pin mapping: RS=${pinRS}, E=${pinE}, D4=${pinD4}, D5=${pinD5}, D6=${pinD6}, D7=${pinD7}`);
// Subscribe to pin changes via PinManager
const pinManager = (avrSimulator as any).pinManager;
if (!pinManager) {
console.warn('[LCD] No pinManager found on AVRSimulator');
return () => { };
}
const unsubscribers: (() => void)[] = [];
if (pinRS !== null) {
unsubscribers.push(pinManager.onPinChange(pinRS, (_p: number, state: boolean) => {
rsState = state;
}));
}
if (pinD4 !== null) {
unsubscribers.push(pinManager.onPinChange(pinD4, (_p: number, state: boolean) => {
d4State = state;
}));
}
if (pinD5 !== null) {
unsubscribers.push(pinManager.onPinChange(pinD5, (_p: number, state: boolean) => {
d5State = state;
}));
}
if (pinD6 !== null) {
unsubscribers.push(pinManager.onPinChange(pinD6, (_p: number, state: boolean) => {
d6State = state;
}));
}
if (pinD7 !== null) {
unsubscribers.push(pinManager.onPinChange(pinD7, (_p: number, state: boolean) => {
d7State = state;
}));
}
// Enable pin: watch for falling edge (HIGH → LOW)
if (pinE !== null) {
unsubscribers.push(pinManager.onPinChange(pinE, (_p: number, state: boolean) => {
const wasHigh = eState;
eState = state;
// Falling edge: data is latched
if (wasHigh && !state) {
onEnableFallingEdge();
}
}));
}
// Initialize display as blank
refreshDisplay();
console.log(`[LCD] ${cols}x${rows} simulation initialized`);
return () => {
unsubscribers.forEach(u => u());
console.log(`[LCD] ${cols}x${rows} simulation cleaned up`);
};
},
};
}
// Register LCD 1602 (16x2)
PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2));
// Register LCD 2004 (20x4)
PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4));

View File

@ -0,0 +1,45 @@
import { AVRSimulator } from '../AVRSimulator';
/**
* Interface for simulation logic mapped to a specific wokwi-element
*/
export interface PartSimulationLogic {
/**
* Called when a digital pin connected to this part changes state.
* Useful for output components (LEDs, buzzers, etc).
*
* @param pinName The name of the pin on the component that changed
* @param state The new digital state (true = HIGH, false = LOW)
* @param element The DOM element of the wokwi component
*/
onPinStateChange?: (pinName: string, state: boolean, element: HTMLElement) => void;
/**
* Called when the simulation starts to attach events or setup periodic tasks.
* Useful for input components (buttons, potentiometers) or complex components (servos).
*
* @param element The DOM element of the wokwi component
* @param avrSimulator The running simulator instance
* @param getArduinoPinHelper Function to find what Arduino pin is connected to a specific component pin
* @returns A cleanup function to remove event listeners when simulation stops
*/
attachEvents?: (
element: HTMLElement,
avrSimulator: AVRSimulator,
getArduinoPinHelper: (componentPinName: string) => number | null
) => () => void;
}
class PartRegistry {
private parts: Map<string, PartSimulationLogic> = new Map();
register(metadataId: string, logic: PartSimulationLogic) {
this.parts.set(metadataId, logic);
}
get(metadataId: string): PartSimulationLogic | undefined {
return this.parts.get(metadataId);
}
}
export const PartSimulationRegistry = new PartRegistry();

View File

@ -0,0 +1,3 @@
export * from './PartSimulationRegistry';
import './BasicParts';
import './ComplexParts';

View File

@ -44,6 +44,7 @@ interface SimulatorState {
removeComponent: (id: string) => void;
updateComponent: (id: string, updates: Partial<Component>) => void;
updateComponentState: (id: string, state: boolean) => void;
handleComponentEvent: (componentId: string, eventName: string, data?: any) => void;
setComponents: (components: Component[]) => void;
// Wire management (Phase 1)
@ -217,11 +218,16 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
updateComponentState: (id, state) => {
set((prevState) => ({
components: prevState.components.map((c) =>
c.id === id ? { ...c, properties: { ...c.properties, state } } : c
c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c
),
}));
},
handleComponentEvent: (_componentId, _eventName, _data) => {
// Legacy UI-based handling can be placed here if needed
// but device simulation events are now in DynamicComponent via PartSimulationRegistry
},
setComponents: (components) => {
set({ components });
},

View File

@ -0,0 +1,191 @@
/**
* Capture Canvas Preview
*
* Captures the real simulator canvas as a preview image for examples
*/
import type { ExampleProject } from '../data/examples';
/**
* Wait for all wokwi-elements components to be defined
*/
async function waitForComponents(): Promise<void> {
const componentTags = [
'wokwi-arduino-uno',
'wokwi-led',
'wokwi-rgb-led',
'wokwi-pushbutton',
];
const promises = componentTags.map((tag) => {
if (customElements.get(tag)) {
return Promise.resolve();
}
return customElements.whenDefined(tag);
});
await Promise.all(promises);
}
/**
* Create a temporary canvas with the example circuit
*/
async function createPreviewCanvas(example: ExampleProject): Promise<HTMLCanvasElement> {
// Wait for components to be loaded
await waitForComponents();
// Create temporary container
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-10000px';
container.style.width = '800px';
container.style.height = '600px';
container.style.backgroundColor = '#1e1e1e';
document.body.appendChild(container);
// Create SVG for rendering
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '800');
svg.setAttribute('height', '600');
svg.style.backgroundColor = '#1e1e1e';
container.appendChild(svg);
// Calculate bounds for all components
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
example.components.forEach((comp) => {
minX = Math.min(minX, comp.x);
maxX = Math.max(maxX, comp.x + 150);
minY = Math.min(minY, comp.y);
maxY = Math.max(maxY, comp.y + 150);
});
// Add padding
const padding = 40;
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
// Calculate scale to fit
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const scaleX = 760 / contentWidth;
const scaleY = 560 / contentHeight;
const scale = Math.min(scaleX, scaleY, 1);
// Center offset
const offsetX = (800 - contentWidth * scale) / 2 - minX * scale;
const offsetY = (600 - contentHeight * scale) / 2 - minY * scale;
// Create group for all components
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
svg.appendChild(group);
// Render each component as foreignObject
for (const comp of example.components) {
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
foreignObject.setAttribute('x', comp.x.toString());
foreignObject.setAttribute('y', comp.y.toString());
foreignObject.setAttribute('width', '150');
foreignObject.setAttribute('height', '150');
// Create the actual wokwi element
const element = document.createElement(comp.type);
// Set properties
Object.entries(comp.properties).forEach(([key, value]) => {
if (key !== 'state') {
element.setAttribute(key, String(value));
}
});
foreignObject.appendChild(element);
group.appendChild(foreignObject);
}
// Draw wires
example.wires.forEach((wire) => {
const startComp = example.components.find((c) => c.id === wire.start.componentId);
const endComp = example.components.find((c) => c.id === wire.end.componentId);
if (startComp && endComp) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', (startComp.x + 75).toString());
line.setAttribute('y1', (startComp.y + 75).toString());
line.setAttribute('x2', (endComp.x + 75).toString());
line.setAttribute('y2', (endComp.y + 75).toString());
line.setAttribute('stroke', wire.color);
line.setAttribute('stroke-width', '3');
group.appendChild(line);
}
});
// Wait for rendering
await new Promise((resolve) => setTimeout(resolve, 500));
// Convert SVG to canvas
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
const ctx = canvas.getContext('2d')!;
// Draw background
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, 800, 600);
// Convert SVG to image
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
// Cleanup
document.body.removeChild(container);
return canvas;
}
/**
* Generate a preview image data URL for an example
*/
export async function generateCanvasPreview(example: ExampleProject): Promise<string> {
try {
const canvas = await createPreviewCanvas(example);
return canvas.toDataURL('image/png');
} catch (error) {
console.error('Failed to generate preview for', example.id, error);
return '';
}
}
/**
* Generate previews for all examples and return a map
*/
export async function generateAllPreviews(
examples: ExampleProject[]
): Promise<Map<string, string>> {
const previews = new Map<string, string>();
for (const example of examples) {
const preview = await generateCanvasPreview(example);
if (preview) {
previews.set(example.id, preview);
}
}
return previews;
}

View File

@ -0,0 +1,125 @@
/**
* Generate SVG Preview for Example Projects
*
* Creates a visual preview of the circuit layout for example cards
*/
import type { ExampleProject } from '../data/examples';
export function generateExamplePreviewSVG(example: ExampleProject): string {
const width = 400;
const height = 250;
const padding = 20;
// Calculate bounds
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
example.components.forEach((comp) => {
minX = Math.min(minX, comp.x);
maxX = Math.max(maxX, comp.x + 100); // Approximate component width
minY = Math.min(minY, comp.y);
maxY = Math.max(maxY, comp.y + 100); // Approximate component height
});
// Calculate scale to fit in preview
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const scaleX = (width - padding * 2) / contentWidth;
const scaleY = (height - padding * 2) / contentHeight;
const scale = Math.min(scaleX, scaleY, 1); // Don't scale up
// Center the content
const offsetX = (width - contentWidth * scale) / 2 - minX * scale;
const offsetY = (height - contentHeight * scale) / 2 - minY * scale;
const components = example.components
.map((comp) => {
const x = comp.x * scale + offsetX;
const y = comp.y * scale + offsetY;
// Simplified component representations
if (comp.type === 'wokwi-arduino-uno') {
return `
<rect x="${x}" y="${y}" width="${70 * scale}" height="${60 * scale}"
fill="#00979D" stroke="#006064" stroke-width="2" rx="3"/>
<text x="${x + 35 * scale}" y="${y + 35 * scale}"
fill="white" font-size="${12 * scale}" text-anchor="middle" font-family="Arial">
Arduino
</text>
`;
} else if (comp.type === 'wokwi-led') {
const color = comp.properties.color || 'red';
return `
<circle cx="${x + 10 * scale}" cy="${y + 10 * scale}" r="${8 * scale}"
fill="${color}" stroke="#666" stroke-width="1.5" opacity="0.9"/>
<circle cx="${x + 10 * scale}" cy="${y + 10 * scale}" r="${4 * scale}"
fill="${color}" opacity="0.5"/>
`;
} else if (comp.type === 'wokwi-rgb-led') {
return `
<circle cx="${x + 10 * scale}" cy="${y + 10 * scale}" r="${10 * scale}"
fill="url(#rgbGradient)" stroke="#666" stroke-width="1.5"/>
<defs>
<linearGradient id="rgbGradient">
<stop offset="0%" stop-color="red"/>
<stop offset="50%" stop-color="green"/>
<stop offset="100%" stop-color="blue"/>
</linearGradient>
</defs>
`;
} else if (comp.type === 'wokwi-pushbutton') {
return `
<rect x="${x}" y="${y}" width="${20 * scale}" height="${20 * scale}"
fill="#333" stroke="#666" stroke-width="1.5" rx="2"/>
<circle cx="${x + 10 * scale}" cy="${y + 10 * scale}" r="${6 * scale}"
fill="#555" stroke="#888" stroke-width="1"/>
`;
} else {
// Generic component
return `
<rect x="${x}" y="${y}" width="${30 * scale}" height="${20 * scale}"
fill="#444" stroke="#666" stroke-width="1.5" rx="2"/>
`;
}
})
.join('');
// Draw simplified wires
const wires = example.wires
.map((wire) => {
const startComp = example.components.find((c) => c.id === wire.start.componentId);
const endComp = example.components.find((c) => c.id === wire.end.componentId);
if (!startComp || !endComp) return '';
const x1 = startComp.x * scale + offsetX + 35 * scale;
const y1 = startComp.y * scale + offsetY + 30 * scale;
const x2 = endComp.x * scale + offsetX + 35 * scale;
const y2 = endComp.y * scale + offsetY + 30 * scale;
return `
<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"
stroke="${wire.color}" stroke-width="2" opacity="0.6"/>
`;
})
.join('');
return `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="${width}" height="${height}" fill="#1a1a1a"/>
<g>
${wires}
${components}
</g>
</svg>
`;
}
export function generateExamplePreviewDataURL(example: ExampleProject): string {
const svg = generateExamplePreviewSVG(example);
const base64 = btoa(unescape(encodeURIComponent(svg)));
return `data:image/svg+xml;base64,${base64}`;
}