import { useSimulatorStore, ARDUINO_POSITION, BOARD_LABELS } from '../../store/useSimulatorStore'; import type { BoardType } from '../../store/useSimulatorStore'; import React, { useEffect, useState, useRef, useCallback } from 'react'; import { ArduinoUno } from '../components-wokwi/ArduinoUno'; import { NanoRP2040 } from '../components-wokwi/NanoRP2040'; import { ComponentPickerModal } from '../ComponentPickerModal'; import { ComponentPropertyDialog } from './ComponentPropertyDialog'; import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent'; import { ComponentRegistry } from '../../services/ComponentRegistry'; import { PinSelector } from './PinSelector'; import { WireLayer } from './WireLayer'; import { PinOverlay } from './PinOverlay'; import { PartSimulationRegistry } from '../../simulation/parts'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import type { ComponentMetadata } from '../../types/component-metadata'; import './SimulatorCanvas.css'; export const SimulatorCanvas = () => { const { boardType, setBoardType, components, running, pinManager, initSimulator, updateComponentState, addComponent, removeComponent, updateComponent, serialMonitorOpen, toggleSerialMonitor, } = useSimulatorStore(); // Wire management from store const startWireCreation = useSimulatorStore((s) => s.startWireCreation); const updateWireInProgress = useSimulatorStore((s) => s.updateWireInProgress); const finishWireCreation = useSimulatorStore((s) => s.finishWireCreation); const cancelWireCreation = useSimulatorStore((s) => s.cancelWireCreation); const wireInProgress = useSimulatorStore((s) => s.wireInProgress); const recalculateAllWirePositions = useSimulatorStore((s) => s.recalculateAllWirePositions); // Component picker modal const [showComponentPicker, setShowComponentPicker] = useState(false); const [registry] = useState(() => ComponentRegistry.getInstance()); const [registryLoaded, setRegistryLoaded] = useState(registry.isLoaded); // Wait for registry to finish loading before rendering components useEffect(() => { if (!registryLoaded) { registry.loadPromise.then(() => setRegistryLoaded(true)); } }, [registry, registryLoaded]); // Component selection const [selectedComponentId, setSelectedComponentId] = useState(null); const [showPinSelector, setShowPinSelector] = useState(false); const [pinSelectorPos, setPinSelectorPos] = useState({ x: 0, y: 0 }); // Component property dialog const [showPropertyDialog, setShowPropertyDialog] = useState(false); const [propertyDialogComponentId, setPropertyDialogComponentId] = useState(null); const [propertyDialogPosition, setPropertyDialogPosition] = useState({ x: 0, y: 0 }); // Click vs drag detection const [clickStartTime, setClickStartTime] = useState(0); const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 }); // Component dragging state const [draggedComponentId, setDraggedComponentId] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); // Canvas ref for coordinate calculations const canvasRef = useRef(null); // Pan & zoom state const [pan, setPan] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); // Use refs during active pan to avoid setState lag const isPanningRef = useRef(false); const panStartRef = useRef({ mouseX: 0, mouseY: 0, panX: 0, panY: 0 }); const panRef = useRef({ x: 0, y: 0 }); const zoomRef = useRef(1); // Convert viewport coords to world (canvas) coords const toWorld = useCallback((screenX: number, screenY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return { x: screenX, y: screenY }; return { x: (screenX - rect.left - panRef.current.x) / zoomRef.current, y: (screenY - rect.top - panRef.current.y) / zoomRef.current, }; }, []); // Initialize simulator on mount useEffect(() => { initSimulator(); }, [initSimulator]); // Recalculate wire positions after web components initialize their pinInfo useEffect(() => { const timer = setTimeout(() => { recalculateAllWirePositions(); }, 500); return () => clearTimeout(timer); }, [recalculateAllWirePositions]); // Connect components to pin manager useEffect(() => { const unsubscribers: (() => void)[] = []; // 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 ); 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 (isBoardComponent(otherEndpoint.componentId)) { const pin = boardPinToNumber(otherEndpoint.componentId, otherEndpoint.pinName); if (pin !== null) { subscribeComponentToPin(component, pin, selfEndpoint.pinName); } } }); } }); return () => { unsubscribers.forEach(unsub => unsub()); }; }, [components, pinManager, updateComponentState]); // Handle keyboard delete useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.key === 'Delete' || e.key === 'Backspace') && selectedComponentId) { removeComponent(selectedComponentId); setSelectedComponentId(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedComponentId, removeComponent]); // Handle component selection from modal const handleSelectComponent = (metadata: ComponentMetadata) => { // Calculate grid position to avoid overlapping // Use existing components count to determine position const componentsCount = components.length; const gridSize = 250; // Space between components const cols = 3; // Components per row const col = componentsCount % cols; const row = Math.floor(componentsCount / cols); const x = 400 + (col * gridSize); const y = 100 + (row * gridSize); const component = createComponentFromMetadata(metadata, x, y); addComponent(component as any); setShowComponentPicker(false); }; // Component selection (double click to open pin selector) const handleComponentDoubleClick = (componentId: string, event: React.MouseEvent) => { event.stopPropagation(); setSelectedComponentId(componentId); setPinSelectorPos({ x: event.clientX, y: event.clientY }); setShowPinSelector(true); }; // Pin assignment const handlePinSelect = (componentId: string, pin: number) => { updateComponent(componentId, { properties: { ...components.find((c) => c.id === componentId)?.properties, pin, }, } as any); }; // Component rotation const handleRotateComponent = (componentId: string) => { const component = components.find((c) => c.id === componentId); if (!component) return; const currentRotation = (component.properties.rotation as number) || 0; updateComponent(componentId, { properties: { ...component.properties, rotation: (currentRotation + 90) % 360, }, } as any); }; // Component dragging handlers const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => { if (showPinSelector || showPropertyDialog) return; e.stopPropagation(); const component = components.find((c) => c.id === componentId); if (!component) return; setClickStartTime(Date.now()); setClickStartPos({ x: e.clientX, y: e.clientY }); const world = toWorld(e.clientX, e.clientY); setDraggedComponentId(componentId); setDragOffset({ x: world.x - component.x, y: world.y - component.y, }); setSelectedComponentId(componentId); }; const handleCanvasMouseMove = (e: React.MouseEvent) => { // Handle active panning (ref-based, no setState lag) if (isPanningRef.current) { const dx = e.clientX - panStartRef.current.mouseX; const dy = e.clientY - panStartRef.current.mouseY; const newPan = { x: panStartRef.current.panX + dx, y: panStartRef.current.panY + dy, }; panRef.current = newPan; // Update the transform directly for zero-lag panning const world = canvasRef.current?.querySelector('.canvas-world') as HTMLElement | null; if (world) { world.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`; } return; } // Handle component dragging if (draggedComponentId) { const world = toWorld(e.clientX, e.clientY); updateComponent(draggedComponentId, { x: Math.max(0, world.x - dragOffset.x), y: Math.max(0, world.y - dragOffset.y), } as any); } // Handle wire creation preview if (wireInProgress) { const world = toWorld(e.clientX, e.clientY); updateWireInProgress(world.x, world.y); } }; const handleCanvasMouseUp = (e: React.MouseEvent) => { // Finish panning — commit ref value to state so React knows the final pan if (isPanningRef.current) { isPanningRef.current = false; setPan({ ...panRef.current }); return; } if (draggedComponentId) { const timeDiff = Date.now() - clickStartTime; const posDiff = Math.sqrt( Math.pow(e.clientX - clickStartPos.x, 2) + Math.pow(e.clientY - clickStartPos.y, 2) ); if (posDiff < 5 && timeDiff < 300) { const component = components.find((c) => c.id === draggedComponentId); if (component) { setPropertyDialogComponentId(draggedComponentId); setPropertyDialogPosition({ x: component.x, y: component.y }); setShowPropertyDialog(true); } } recalculateAllWirePositions(); setDraggedComponentId(null); } }; // Start panning on middle-click or right-click const handleCanvasMouseDown = (e: React.MouseEvent) => { if (e.button === 1 || e.button === 2) { e.preventDefault(); isPanningRef.current = true; panStartRef.current = { mouseX: e.clientX, mouseY: e.clientY, panX: panRef.current.x, panY: panRef.current.y, }; } }; // Zoom centered on cursor const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor)); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Keep the world point under the cursor fixed const worldX = (mx - panRef.current.x) / zoomRef.current; const worldY = (my - panRef.current.y) / zoomRef.current; const newPan = { x: mx - worldX * newZoom, y: my - worldY * newZoom, }; zoomRef.current = newZoom; panRef.current = newPan; setZoom(newZoom); setPan(newPan); }; const handleResetView = () => { zoomRef.current = 1; panRef.current = { x: 0, y: 0 }; setZoom(1); setPan({ x: 0, y: 0 }); }; // Wire creation via pin clicks const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => { // Close property dialog when starting wire creation if (showPropertyDialog) { setShowPropertyDialog(false); } if (wireInProgress) { // Finish wire creation finishWireCreation({ componentId, pinName, x, y, }); } else { // Start wire creation startWireCreation({ componentId, pinName, x, y, }); } }; // Keyboard handlers for wires useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && wireInProgress) { cancelWireCreation(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [wireInProgress, cancelWireCreation]); // Recalculate wire positions when components change (e.g., when loading an example) useEffect(() => { // Wait for components to render and pinInfo to be available // Use multiple retries to ensure pinInfo is ready const timers: ReturnType[] = []; // Try at 100ms, 300ms, and 500ms to ensure all components have rendered timers.push(setTimeout(() => recalculateAllWirePositions(), 100)); timers.push(setTimeout(() => recalculateAllWirePositions(), 300)); timers.push(setTimeout(() => recalculateAllWirePositions(), 500)); return () => timers.forEach(t => clearTimeout(t)); }, [components, recalculateAllWirePositions]); // Render component using dynamic renderer const renderComponent = (component: any) => { const metadata = registry.getById(component.metadataId); if (!metadata) { console.warn(`Metadata not found for component: ${component.metadataId}`); return null; } const isSelected = selectedComponentId === component.id; // Always show pins for better UX when creating wires const showPinsForComponent = true; return ( { // Only handle UI events when simulation is NOT running if (!running) { handleComponentMouseDown(component.id, e); } }} onDoubleClick={(e) => { // Only handle UI events when simulation is NOT running if (!running) { handleComponentDoubleClick(component.id, e); } }} /> {/* Pin overlay for wire creation - hide when running */} {!running && ( )} ); }; return (
{/* Main Canvas */}
{/* Status LED */} {/* Board selector */} {/* Serial Monitor toggle */}
{/* Zoom controls */}
{/* Component count */} {components.length} {/* Add Component */}
{ isPanningRef.current = false; setPan({ ...panRef.current }); setDraggedComponentId(null); }} onWheel={handleWheel} onContextMenu={(e) => e.preventDefault()} onClick={() => setSelectedComponentId(null)} style={{ cursor: isPanningRef.current ? 'grabbing' : wireInProgress ? 'crosshair' : 'default' }} > {/* Infinite world — pan+zoom applied here */}
{/* Wire Layer - Renders below all components */} {/* Board visual — switches based on selected board type */} {boardType === 'arduino-uno' ? ( c.id === 'led-builtin')?.properties.state)} /> ) : ( c.id === 'led-builtin')?.properties.state)} /> )} {/* Board pin overlay */} {/* Components using wokwi-elements */}
{registryLoaded && components.map(renderComponent)}
{/* Pin Selector Modal */} {showPinSelector && selectedComponentId && ( c.id === selectedComponentId)?.metadataId || 'unknown' } currentPin={ components.find((c) => c.id === selectedComponentId)?.properties.pin as number | undefined } onPinSelect={handlePinSelect} onClose={() => setShowPinSelector(false)} position={pinSelectorPos} /> )} {/* Component Property Dialog */} {showPropertyDialog && propertyDialogComponentId && (() => { const component = components.find((c) => c.id === propertyDialogComponentId); const metadata = component ? registry.getById(component.metadataId) : null; if (!component || !metadata) return null; const element = document.getElementById(propertyDialogComponentId); const pinInfo = element ? (element as any).pinInfo : []; return ( setShowPropertyDialog(false)} onRotate={handleRotateComponent} onDelete={(id) => { removeComponent(id); setShowPropertyDialog(false); }} /> ); })()} {/* Component Picker Modal */} setShowComponentPicker(false)} onSelectComponent={handleSelectComponent} />
); };