From 3ace72d0f85f0de3d6257f447cea1381fc7e87ce Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 7 Mar 2026 02:00:56 -0300 Subject: [PATCH] feat: implement pan and zoom functionality in simulator canvas --- .../components/simulator/SimulatorCanvas.css | 66 +++++- .../components/simulator/SimulatorCanvas.tsx | 205 +++++++++++++----- 2 files changed, 214 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.css b/frontend/src/components/simulator/SimulatorCanvas.css index 58140b7..7b40171 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.css +++ b/frontend/src/components/simulator/SimulatorCanvas.css @@ -150,16 +150,27 @@ cursor: not-allowed; } -/* ── Canvas content ──────────────────────────────── */ +/* ── Canvas content (viewport) ───────────────────── */ .canvas-content { flex: 1; - padding: 20px; position: relative; - overflow: auto; + overflow: hidden; background-color: #1a1a1a; background-image: repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px), repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.025) 19px, rgba(255,255,255,0.025) 20px); + user-select: none; +} + +/* ── Infinite canvas world ───────────────────────── */ +.canvas-world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + /* min size so content is reachable even when empty */ + width: 4000px; + height: 3000px; } /* ── Components area ─────────────────────────────── */ @@ -171,6 +182,55 @@ bottom: 0; } +/* ── Zoom controls ───────────────────────────────── */ +.zoom-controls { + display: flex; + align-items: center; + gap: 2px; + background: #1e1e1e; + border: 1px solid #3a3a3a; + border-radius: 5px; + padding: 0 2px; + height: 28px; +} + +.zoom-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 3px; + color: #9d9d9d; + cursor: pointer; + flex-shrink: 0; +} + +.zoom-btn:hover { + background: #3a3a3a; + color: #ccc; +} + +.zoom-level { + min-width: 42px; + text-align: center; + background: transparent; + border: none; + color: #9d9d9d; + font-size: 11px; + font-family: monospace; + cursor: pointer; + padding: 0 2px; + border-radius: 3px; +} + +.zoom-level:hover { + background: #3a3a3a; + color: #ccc; +} + .component-label { font-size: 11px; background-color: #252526; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 469ffa5..0d57805 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1,6 +1,6 @@ import { useSimulatorStore, ARDUINO_POSITION, BOARD_LABELS } from '../../store/useSimulatorStore'; import type { BoardType } from '../../store/useSimulatorStore'; -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { ArduinoUno } from '../components-wokwi/ArduinoUno'; import { NanoRP2040 } from '../components-wokwi/NanoRP2040'; import { ComponentPickerModal } from '../ComponentPickerModal'; @@ -72,6 +72,25 @@ export const SimulatorCanvas = () => { // 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(); @@ -208,63 +227,73 @@ export const SimulatorCanvas = () => { // Component dragging handlers const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => { - // Don't start dragging if we're clicking on the pin selector or property dialog if (showPinSelector || showPropertyDialog) return; e.stopPropagation(); const component = components.find((c) => c.id === componentId); - if (!component || !canvasRef.current) return; + if (!component) return; - // Record click start for click vs drag detection setClickStartTime(Date.now()); setClickStartPos({ x: e.clientX, y: e.clientY }); - // Get canvas position to convert viewport coords to canvas coords - const canvasRect = canvasRef.current.getBoundingClientRect(); - - // Calculate offset in canvas coordinate system + const world = toWorld(e.clientX, e.clientY); setDraggedComponentId(componentId); setDragOffset({ - x: (e.clientX - canvasRect.left) - component.x, - y: (e.clientY - canvasRect.top) - component.y, + x: world.x - component.x, + y: world.y - component.y, }); setSelectedComponentId(componentId); }; const handleCanvasMouseMove = (e: React.MouseEvent) => { - if (!canvasRef.current) return; + // 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 canvasRect = canvasRef.current.getBoundingClientRect(); - const newX = e.clientX - canvasRect.left - dragOffset.x; - const newY = e.clientY - canvasRect.top - dragOffset.y; - + const world = toWorld(e.clientX, e.clientY); updateComponent(draggedComponentId, { - x: Math.max(0, newX), - y: Math.max(0, newY), + x: Math.max(0, world.x - dragOffset.x), + y: Math.max(0, world.y - dragOffset.y), } as any); } // Handle wire creation preview - if (wireInProgress && canvasRef.current) { - const canvasRect = canvasRef.current.getBoundingClientRect(); - const currentX = e.clientX - canvasRect.left; - const currentY = e.clientY - canvasRect.top; - updateWireInProgress(currentX, currentY); + 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) { - // Check if this was a click or a drag const timeDiff = Date.now() - clickStartTime; const posDiff = Math.sqrt( Math.pow(e.clientX - clickStartPos.x, 2) + Math.pow(e.clientY - clickStartPos.y, 2) ); - // If moved < 5px and time < 300ms, treat as click if (posDiff < 5 && timeDiff < 300) { const component = components.find((c) => c.id === draggedComponentId); if (component) { @@ -274,12 +303,57 @@ export const SimulatorCanvas = () => { } } - // Recalculate wire positions after moving component 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 @@ -418,6 +492,19 @@ export const SimulatorCanvas = () => {
+ {/* Zoom controls */} +
+ + + +
+ {/* Component count */} @@ -445,42 +532,52 @@ export const SimulatorCanvas = () => {
{ isPanningRef.current = false; setPan({ ...panRef.current }); setDraggedComponentId(null); }} + onWheel={handleWheel} + onContextMenu={(e) => e.preventDefault()} onClick={() => setSelectedComponentId(null)} - style={{ cursor: wireInProgress ? 'crosshair' : 'default' }} + style={{ cursor: isPanningRef.current ? 'grabbing' : wireInProgress ? 'crosshair' : 'default' }} > - {/* Wire Layer - Renders below all components */} - + {/* 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)} + {/* 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 */} + - ) : ( - c.id === 'led-builtin')?.properties.state)} - /> - )} - {/* Board pin overlay */} - - - {/* Components using wokwi-elements */} -
{registryLoaded && components.map(renderComponent)}
+ {/* Components using wokwi-elements */} +
{registryLoaded && components.map(renderComponent)}
+