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} + />