From 5fc57c264df442fd12ab54e73b24973379184aff Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 16 Jul 2026 05:51:29 +0200 Subject: [PATCH] fix(minimap): live-track pan/zoom + real board footprints + bigger map The red viewport rectangle read the React `pan` prop, but the canvas pans by mutating panRef + the .canvas-world transform directly (no setState until pointer-up, for zero-lag dragging). So while you dragged the canvas the rectangle sat frozen and only jumped at the end. The minimap now also receives panRef/zoomRef and mirrors them via a requestAnimationFrame loop that setStates only on change, so the rect follows the canvas every frame while keeping the canvas render-free during the gesture. Also: boards were all drawn as one fixed 120x90 world rectangle regardless of the actual board, misrepresenting the layout. Use the real per-board BOARD_SIZE (now exported from BoardOnCanvas) so each footprint is proportional. And enlarge the map 100x75 -> 160x120 (same 4:3 as the 4000x3000 world) so it's usable. --- .../components/simulator/BoardOnCanvas.tsx | 2 +- .../components/simulator/CanvasMinimap.tsx | 121 +++++++++++------- .../components/simulator/SimulatorCanvas.tsx | 2 + 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/simulator/BoardOnCanvas.tsx b/frontend/src/components/simulator/BoardOnCanvas.tsx index b7b74451..1fba266c 100644 --- a/frontend/src/components/simulator/BoardOnCanvas.tsx +++ b/frontend/src/components/simulator/BoardOnCanvas.tsx @@ -27,7 +27,7 @@ import { PinOverlay } from './PinOverlay'; // Board visual dimensions (width × height) for the drag-overlay sizing. // ESP32 sizes match the wokwi-boards SVG rendered at 5 px/mm. -const BOARD_SIZE: Record = { +export const BOARD_SIZE: Record = { // wokwi-elements: rendered at 96 dpi — 1mm = 3.7795px 'arduino-uno': { w: 274, h: 202 }, // 72.58mm × 53.34mm 'arduino-nano': { w: 170, h: 67 }, // 44.9mm × 17.8mm diff --git a/frontend/src/components/simulator/CanvasMinimap.tsx b/frontend/src/components/simulator/CanvasMinimap.tsx index ccb7f71a..164157ea 100644 --- a/frontend/src/components/simulator/CanvasMinimap.tsx +++ b/frontend/src/components/simulator/CanvasMinimap.tsx @@ -7,42 +7,53 @@ * .canvas-content is the viewport, sized to the available area at runtime. * .canvas-world's transform is `translate(pan.x, pan.y) scale(zoom)`. * - * The minimap is MINIMAP_W × MINIMAP_H. SCALE = MINIMAP_W / WORLD_W = 0.05. - * A world point (wx, wy) renders at (wx * SCALE, wy * SCALE). + * The minimap is MINIMAP_W × MINIMAP_H (same 4:3 aspect as the world, so + * SCALE_X === SCALE_Y). A world point (wx, wy) renders at (wx*SCALE, wy*SCALE). * - * The viewport rectangle drawn inside the minimap shows what fraction of - * the world is currently visible: + * The viewport rectangle shows what fraction of the world is visible: * rect.x = -pan.x / zoom * SCALE * rect.y = -pan.y / zoom * SCALE * rect.w = viewport.width / zoom * SCALE * rect.h = viewport.height / zoom * SCALE * + * LIVE TRACKING: while the user pans/zooms, SimulatorCanvas deliberately + * bypasses React state — it writes panRef/zoomRef and mutates the + * .canvas-world transform directly for zero-lag dragging, committing to + * state only on pointer-up. The minimap therefore CANNOT rely on the pan + * prop alone (the red rectangle would freeze mid-drag — reported bug). It + * reads panRef/zoomRef inside a requestAnimationFrame loop and updates its + * own tiny local state only when the values actually change: the canvas + * stays render-free during the gesture, and only this small component + * re-renders, at most once per frame. + * * Interaction: - * - Mouse / touch down OUTSIDE the rectangle → teleport: re-center the - * viewport on the clicked world point. - * - Mouse / touch down INSIDE the rectangle → drag mode: live-pan the - * canvas. Inverse of (delta in minimap space) → world delta. + * - Pointer down OUTSIDE the rectangle → teleport: center the viewport + * on the clicked world point. + * - Pointer down INSIDE the rectangle → drag mode: live-pan the canvas. */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { Component } from '../../types/components'; import type { BoardInstance } from '../../types/board'; +import { BOARD_SIZE } from './BoardOnCanvas'; import './CanvasMinimap.css'; -const MINIMAP_W = 100; -const MINIMAP_H = 75; +const MINIMAP_W = 160; +const MINIMAP_H = 120; const WORLD_W = 4000; const WORLD_H = 3000; const SCALE_X = MINIMAP_W / WORLD_W; const SCALE_Y = MINIMAP_H / WORLD_H; -// Board footprint is ~120 × 90 in world units; render at that size so the -// minimap actually shows where each board lives. -const BOARD_W_WORLD = 120; -const BOARD_H_WORLD = 90; +// Fallback footprint for boards missing from the BOARD_SIZE table. +const BOARD_FALLBACK = { w: 300, h: 200 }; interface Props { pan: { x: number; y: number }; zoom: number; setPan: (p: { x: number; y: number }) => void; + /** Live pan/zoom refs from SimulatorCanvas. During a canvas drag these are + * the ONLY up-to-date source — React state lags until pointer-up. */ + panRef: React.MutableRefObject<{ x: number; y: number }>; + zoomRef: React.MutableRefObject; components: Component[]; boards: BoardInstance[]; /** Ref to the .canvas-content element — we read its size to compute the @@ -55,10 +66,28 @@ export const CanvasMinimap: React.FC = ({ pan, zoom, setPan, + panRef, + zoomRef, components, boards, viewportRef, }) => { + // Live pan/zoom mirrored from the refs via rAF (see header comment). + const [live, setLive] = useState({ x: pan.x, y: pan.y, zoom }); + useEffect(() => { + let raf = 0; + const tick = () => { + const p = panRef.current; + const z = zoomRef.current; + setLive((cur) => + cur.x === p.x && cur.y === p.y && cur.zoom === z ? cur : { x: p.x, y: p.y, zoom: z }, + ); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [panRef, zoomRef]); + // Visible viewport size in CSS pixels — listens for resize so the // rectangle stays accurate when the user opens / closes side panels. const [vp, setVp] = useState<{ w: number; h: number }>({ w: 0, h: 0 }); @@ -75,11 +104,11 @@ export const CanvasMinimap: React.FC = ({ return () => ro.disconnect(); }, [viewportRef]); - // The viewport rectangle in minimap coordinates. - const rectX = (-pan.x / zoom) * SCALE_X; - const rectY = (-pan.y / zoom) * SCALE_Y; - const rectW = (vp.w / zoom) * SCALE_X; - const rectH = (vp.h / zoom) * SCALE_Y; + // The viewport rectangle in minimap coordinates (from LIVE values). + const rectX = (-live.x / live.zoom) * SCALE_X; + const rectY = (-live.y / live.zoom) * SCALE_Y; + const rectW = (vp.w / live.zoom) * SCALE_X; + const rectH = (vp.h / live.zoom) * SCALE_Y; // Clamp the rectangle to the minimap bounds so it never paints outside // (transiently during pinch-zoom-out, and persistently when the user @@ -113,30 +142,32 @@ export const CanvasMinimap: React.FC = ({ // is 4000×3000; minimum visible is whatever fits at the current zoom. const clampPan = useCallback( (next: { x: number; y: number }): { x: number; y: number } => { + const z = zoomRef.current; // Visible-area limits expressed in pan-space: // pan.x = 0 → world x=0 at left edge of viewport. // pan.x = -(WORLD_W*zoom - vp.w) → world right edge at right of viewport. - const minX = -(WORLD_W * zoom - vp.w); - const minY = -(WORLD_H * zoom - vp.h); + const minX = -(WORLD_W * z - vp.w); + const minY = -(WORLD_H * z - vp.h); return { x: Math.min(0, Math.max(minX, next.x)), y: Math.min(0, Math.max(minY, next.y)), }; }, - [zoom, vp.w, vp.h], + [zoomRef, vp.w, vp.h], ); const teleportTo = useCallback( (worldX: number, worldY: number) => { + const z = zoomRef.current; // Center the viewport on (worldX, worldY). setPan( clampPan({ - x: -worldX * zoom + vp.w / 2, - y: -worldY * zoom + vp.h / 2, + x: -worldX * z + vp.w / 2, + y: -worldY * z + vp.h / 2, }), ); }, - [zoom, vp.w, vp.h, setPan, clampPan], + [zoomRef, vp.w, vp.h, setPan, clampPan], ); const onPointerDown = useCallback( @@ -157,8 +188,8 @@ export const CanvasMinimap: React.FC = ({ dragRef.current = { mouseX: e.clientX, mouseY: e.clientY, - panX: pan.x, - panY: pan.y, + panX: panRef.current.x, + panY: panRef.current.y, }; e.currentTarget.setPointerCapture(e.pointerId); } else { @@ -166,7 +197,7 @@ export const CanvasMinimap: React.FC = ({ teleportTo(localX / SCALE_X, localY / SCALE_Y); } }, - [clampedX, clampedY, clampedW, clampedH, pan, teleportTo], + [clampedX, clampedY, clampedW, clampedH, panRef, teleportTo], ); useEffect(() => { @@ -177,10 +208,11 @@ export const CanvasMinimap: React.FC = ({ const dy = e.clientY - drag.mouseY; // (delta in minimap px) / SCALE = (delta in world units, unscaled). // (delta in world units) * zoom = (delta to add to pan, but inverted). + const z = zoomRef.current; setPan( clampPan({ - x: drag.panX - (dx / SCALE_X) * zoom, - y: drag.panY - (dy / SCALE_Y) * zoom, + x: drag.panX - (dx / SCALE_X) * z, + y: drag.panY - (dy / SCALE_Y) * z, }), ); }; @@ -195,7 +227,7 @@ export const CanvasMinimap: React.FC = ({ window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; - }, [zoom, setPan, clampPan]); + }, [zoomRef, setPan, clampPan]); return (
= ({ aria-label="Canvas minimap" >
- {boards.map((b) => ( -
- ))} + {boards.map((b) => { + const size = BOARD_SIZE[b.boardKind] ?? BOARD_FALLBACK; + return ( +
+ ); + })} {components.map((c) => (
{ panRef.current = p; setPan(p); }} + panRef={panRef} + zoomRef={zoomRef} components={components} boards={boards} viewportRef={canvasRef}