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.
This commit is contained in:
David Montero Crespo 2026-07-16 05:51:29 +02:00
parent 0d7dad16dd
commit 5fc57c264d
3 changed files with 81 additions and 44 deletions

View File

@ -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<string, { w: number; h: number }> = {
export const BOARD_SIZE: Record<string, { w: number; h: number }> = {
// 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

View File

@ -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<number>;
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<Props> = ({
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<Props> = ({
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<Props> = ({
// 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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, [zoom, setPan, clampPan]);
}, [zoomRef, setPan, clampPan]);
return (
<div
@ -206,18 +238,21 @@ export const CanvasMinimap: React.FC<Props> = ({
aria-label="Canvas minimap"
>
<div className="canvas-minimap-world">
{boards.map((b) => (
<div
key={b.id}
className="canvas-minimap-board"
style={{
left: b.x * SCALE_X,
top: b.y * SCALE_Y,
width: BOARD_W_WORLD * SCALE_X,
height: BOARD_H_WORLD * SCALE_Y,
}}
/>
))}
{boards.map((b) => {
const size = BOARD_SIZE[b.boardKind] ?? BOARD_FALLBACK;
return (
<div
key={b.id}
className="canvas-minimap-board"
style={{
left: b.x * SCALE_X,
top: b.y * SCALE_Y,
width: Math.max(2, size.w * SCALE_X),
height: Math.max(2, size.h * SCALE_Y),
}}
/>
);
})}
{components.map((c) => (
<div
key={c.id}

View File

@ -2678,6 +2678,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
panRef.current = p;
setPan(p);
}}
panRef={panRef}
zoomRef={zoomRef}
components={components}
boards={boards}
viewportRef={canvasRef}