diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index 438ba1d..b990c8b 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -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", diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index c78a80c..4b3816f 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -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 = ({ const containerRef = useRef(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 = ({ // 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 (
= ({ onLoadExample >
{example.thumbnail ? ( - {example.title} + {example.title} ) : ( -
- {getCategoryIcon(example.category)} - - {example.components.length} component{example.components.length !== 1 ? 's' : ''} - +
+
{getCategoryIcon(example.category)}
+
+
+ {example.components.length} component{example.components.length !== 1 ? 's' : ''} +
+
+ {example.wires.length} wire{example.wires.length !== 1 ? 's' : ''} +
+
)}
diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 787b9dd..e3ce381 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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 = () => { c.id === 'led-builtin')?.properties.state || false} + led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)} /> {/* Arduino pin overlay */} diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 9de1c98..5a0f398 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -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 + +// 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 diff --git a/frontend/src/pages/ExamplesPage.tsx b/frontend/src/pages/ExamplesPage.tsx index c96f758..f686b72 100644 --- a/frontend/src/pages/ExamplesPage.tsx +++ b/frontend/src/pages/ExamplesPage.tsx @@ -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, diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 3b7e1bd..2d984b3 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -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); + } + } } diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts new file mode 100644 index 0000000..8c1e0b9 --- /dev/null +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -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 + } +}); diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts new file mode 100644 index 0000000..e0e0bcd --- /dev/null +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -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 (HIGH→LOW) + * 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)); + diff --git a/frontend/src/simulation/parts/PartSimulationRegistry.ts b/frontend/src/simulation/parts/PartSimulationRegistry.ts new file mode 100644 index 0000000..c53168b --- /dev/null +++ b/frontend/src/simulation/parts/PartSimulationRegistry.ts @@ -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 = 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(); diff --git a/frontend/src/simulation/parts/index.ts b/frontend/src/simulation/parts/index.ts new file mode 100644 index 0000000..e7a1d14 --- /dev/null +++ b/frontend/src/simulation/parts/index.ts @@ -0,0 +1,3 @@ +export * from './PartSimulationRegistry'; +import './BasicParts'; +import './ComplexParts'; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index e0c54cb..0f6272b 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -44,6 +44,7 @@ interface SimulatorState { removeComponent: (id: string) => void; updateComponent: (id: string, updates: Partial) => 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((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 }); }, diff --git a/frontend/src/utils/captureCanvasPreview.ts b/frontend/src/utils/captureCanvasPreview.ts new file mode 100644 index 0000000..fa4c3d0 --- /dev/null +++ b/frontend/src/utils/captureCanvasPreview.ts @@ -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 { + 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 { + // 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 { + 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> { + const previews = new Map(); + + for (const example of examples) { + const preview = await generateCanvasPreview(example); + if (preview) { + previews.set(example.id, preview); + } + } + + return previews; +} diff --git a/frontend/src/utils/generateExamplePreview.tsx b/frontend/src/utils/generateExamplePreview.tsx new file mode 100644 index 0000000..aa8c6ee --- /dev/null +++ b/frontend/src/utils/generateExamplePreview.tsx @@ -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 ` + + + Arduino + + `; + } else if (comp.type === 'wokwi-led') { + const color = comp.properties.color || 'red'; + return ` + + + `; + } else if (comp.type === 'wokwi-rgb-led') { + return ` + + + + + + + + + `; + } else if (comp.type === 'wokwi-pushbutton') { + return ` + + + `; + } else { + // Generic component + return ` + + `; + } + }) + .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 ` + + `; + }) + .join(''); + + return ` + + + + ${wires} + ${components} + + + `; +} + +export function generateExamplePreviewDataURL(example: ExampleProject): string { + const svg = generateExamplePreviewSVG(example); + const base64 = btoa(unescape(encodeURIComponent(svg))); + return `data:image/svg+xml;base64,${base64}`; +}