From dcf98fd0e506ce126ac23eacafce0d0b5a6f6c4d Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Wed, 13 May 2026 22:29:46 +0200 Subject: [PATCH] feat(canvas): minimap with draggable viewport in the bottom-right corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a 200×150 px overview of the whole 4000×3000 world in the bottom-right corner of .canvas-content. Boards show as filled blue rectangles, components as small white dots, and the current viewport appears as an outlined rectangle the user can drag to pan. Geometry mirrors the canvas's existing pan+zoom model: SCALE_X = MINIMAP_W / WORLD_W = 0.05 rect.x = -pan.x / zoom * SCALE_X rect.w = viewport.width / zoom * SCALE_X Two interaction modes, decided at pointerdown by hit-testing the rectangle: - Inside the rect → drag-pan: keep updating pan as the pointer moves, with delta in minimap-px converted back to world units by (delta / SCALE) * zoom. - Outside the rect → teleport: re-center the viewport on the clicked world point. Pan is clamped so the viewport rectangle never escapes the minimap bounds (matches the canvas's implicit world boundaries at 4000×3000). ResizeObserver on the canvas-content keeps the rect accurate when the user toggles side panels or resizes the window. Mobile: at ≤720 px width the minimap shrinks to 140×105 px so it doesn't eat too much of the canvas. Touch events go through the same pointerdown / pointermove path — no separate touch code path needed thanks to Pointer Events. Bundles with: matching CSS file, import + JSX hookup inside .canvas-content's render tree. --- .../components/simulator/CanvasMinimap.css | 78 ++++++ .../components/simulator/CanvasMinimap.tsx | 239 ++++++++++++++++++ .../components/simulator/SimulatorCanvas.tsx | 17 ++ 3 files changed, 334 insertions(+) create mode 100644 frontend/src/components/simulator/CanvasMinimap.css create mode 100644 frontend/src/components/simulator/CanvasMinimap.tsx diff --git a/frontend/src/components/simulator/CanvasMinimap.css b/frontend/src/components/simulator/CanvasMinimap.css new file mode 100644 index 00000000..b1ea4ce8 --- /dev/null +++ b/frontend/src/components/simulator/CanvasMinimap.css @@ -0,0 +1,78 @@ +/* Minimap overlay — bottom-right corner of .canvas-content. */ +.canvas-minimap { + position: absolute; + bottom: 16px; + right: 16px; + z-index: 20; + + /* Frame: subtle dark surface that sits one step above the canvas bg. */ + background: rgba(20, 20, 22, 0.9); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); + + /* Disable text selection during drag — touch-action:none on the + * canvas-content already prevents browser-level pan/zoom, but we set + * it here too so the minimap works inside other containers. */ + user-select: none; + touch-action: none; + overflow: hidden; + cursor: crosshair; +} + +.canvas-minimap-world { + position: absolute; + inset: 0; +} + +/* Boards are the visual anchor — render them in the brand blue with + * enough opacity to dominate over the component dots. */ +.canvas-minimap-board { + position: absolute; + background: rgba(0, 113, 227, 0.55); + border: 0.5px solid rgba(0, 113, 227, 0.85); + border-radius: 1px; + pointer-events: none; +} + +/* Components: small fixed-size dots, slightly transparent so a cluster + * still reads as 'busy area' without becoming a solid bar. */ +.canvas-minimap-component { + position: absolute; + width: 3px; + height: 3px; + background: rgba(255, 255, 255, 0.55); + border-radius: 1px; + pointer-events: none; +} + +/* Viewport rectangle — white outline + slight inner fill. The + * pointer-events: none lets the minimap's pointerdown handler decide + * whether the click landed inside (drag) or outside (teleport) + * based on the calculated rect bounds. */ +.canvas-minimap-viewport { + position: absolute; + border: 1.5px solid rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + pointer-events: none; + /* No transition on left/top — those values change every frame during + * a pan and a transition would lag the user's drag. */ +} + +/* Hover state on the minimap itself: subtle brightness bump so the user + * knows it's interactive. */ +.canvas-minimap:hover { + border-color: rgba(255, 255, 255, 0.22); +} + +@media (max-width: 720px) { + /* On phones the minimap eats too much of the canvas; shrink it. */ + .canvas-minimap { + width: 140px !important; + height: 105px !important; + bottom: 12px; + right: 12px; + } +} diff --git a/frontend/src/components/simulator/CanvasMinimap.tsx b/frontend/src/components/simulator/CanvasMinimap.tsx new file mode 100644 index 00000000..9eb160ec --- /dev/null +++ b/frontend/src/components/simulator/CanvasMinimap.tsx @@ -0,0 +1,239 @@ +/** + * CanvasMinimap — bottom-right corner overview of the whole canvas with a + * draggable viewport rectangle. + * + * Geometry mirrors the canvas: + * .canvas-world is 4000×3000 px in world space. + * .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 viewport rectangle drawn inside the minimap shows what fraction of + * the world is currently 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 + * + * 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. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Component } from '../../types/components'; +import type { BoardInstance } from '../../types/board'; +import './CanvasMinimap.css'; + +const MINIMAP_W = 200; +const MINIMAP_H = 150; +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; + +interface Props { + pan: { x: number; y: number }; + zoom: number; + setPan: (p: { x: number; y: number }) => void; + components: Component[]; + boards: BoardInstance[]; + /** Ref to the .canvas-content element — we read its size to compute the + * viewport rectangle, since the canvas viewport changes when the user + * resizes their window or toggles a sidebar. */ + viewportRef: React.RefObject; +} + +export const CanvasMinimap: React.FC = ({ + pan, + zoom, + setPan, + components, + boards, + viewportRef, +}) => { + // 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 }); + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setVp({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + 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; + + // Drag state. We use a ref + window listeners (rather than React's + // onMouseMove on the minimap div) so the gesture keeps working even if + // the cursor leaves the minimap during a fast pan. + const dragRef = useRef< + | { + // Mouse position at mousedown. + mouseX: number; + mouseY: number; + // Pan at mousedown — we add (delta-converted-to-world) to this. + panX: number; + panY: number; + } + | null + >(null); + + const minimapRef = useRef(null); + + // Clamp pan so the viewport rectangle never escapes the minimap. World + // is 4000×3000; minimum visible is whatever fits at the current zoom. + const clampPan = useCallback( + (next: { x: number; y: number }): { x: number; y: number } => { + // 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); + return { + x: Math.min(0, Math.max(minX, next.x)), + y: Math.min(0, Math.max(minY, next.y)), + }; + }, + [zoom, vp.w, vp.h], + ); + + const teleportTo = useCallback( + (worldX: number, worldY: number) => { + // Center the viewport on (worldX, worldY). + setPan( + clampPan({ + x: -worldX * zoom + vp.w / 2, + y: -worldY * zoom + vp.h / 2, + }), + ); + }, + [zoom, vp.w, vp.h, setPan, clampPan], + ); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + e.stopPropagation(); + const minimap = minimapRef.current; + if (!minimap) return; + const rect = minimap.getBoundingClientRect(); + const localX = e.clientX - rect.left; + const localY = e.clientY - rect.top; + const insideRect = + localX >= rectX && + localX <= rectX + rectW && + localY >= rectY && + localY <= rectY + rectH; + if (insideRect) { + // Drag mode — record start state, window listeners do the rest. + dragRef.current = { + mouseX: e.clientX, + mouseY: e.clientY, + panX: pan.x, + panY: pan.y, + }; + e.currentTarget.setPointerCapture(e.pointerId); + } else { + // Teleport — convert minimap-local click to world coords. + teleportTo(localX / SCALE_X, localY / SCALE_Y); + } + }, + [rectX, rectY, rectW, rectH, pan, teleportTo], + ); + + useEffect(() => { + const onMove = (e: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const dx = e.clientX - drag.mouseX; + 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). + setPan( + clampPan({ + x: drag.panX - (dx / SCALE_X) * zoom, + y: drag.panY - (dy / SCALE_Y) * zoom, + }), + ); + }; + const onUp = () => { + dragRef.current = null; + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }, [zoom, setPan, clampPan]); + + // Clamp the rendered rectangle to the minimap bounds so it never paints + // outside (happens transiently during pinch-zoom-out). + const clampedX = Math.max(0, Math.min(MINIMAP_W - rectW, rectX)); + const clampedY = Math.max(0, Math.min(MINIMAP_H - rectH, rectY)); + const clampedW = Math.min(rectW, MINIMAP_W); + const clampedH = Math.min(rectH, MINIMAP_H); + + return ( +
+
+ {boards.map((b) => ( +
+ ))} + {components.map((c) => ( +
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 4bd9eaa9..a7363f9a 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -19,6 +19,7 @@ import { WireLayer } from './WireLayer'; import type { SegmentHandle, WaypointHandle, AlignmentGuide } from './WireLayer'; import { ElectricalOverlay } from '../analog-ui/ElectricalOverlay'; import { BoardOnCanvas } from './BoardOnCanvas'; +import { CanvasMinimap } from './CanvasMinimap'; import { PartSimulationRegistry } from '../../simulation/parts'; import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils'; import { isSpiceMapped } from '../../simulation/spice/componentToSpice'; @@ -2497,6 +2498,22 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } return null; })()} + + {/* Minimap — small overview of the world with a draggable viewport + rectangle, anchored to the canvas-content bottom-right corner. + Sits inside .canvas-content (not .canvas-world) so it stays + fixed while the world pans / zooms. */} + { + panRef.current = p; + setPan(p); + }} + components={components} + boards={boards} + viewportRef={canvasRef} + />