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
parent
cf95aeacd8
commit
ef7e86bc1e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './PartSimulationRegistry';
|
||||
import './BasicParts';
|
||||
import './ComplexParts';
|
||||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
Loading…
Reference in New Issue