Merge pull request #178 from davidmonterocrespo24/feat-canvas-minimap

feat(canvas): minimap with draggable viewport in the bottom-right corner
This commit is contained in:
David Montero Crespo 2026-05-13 17:36:29 -03:00 committed by GitHub
commit a4893a0da7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 334 additions and 0 deletions

View File

@ -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;
}
}

View File

@ -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<HTMLElement>;
}
export const CanvasMinimap: React.FC<Props> = ({
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<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div
ref={minimapRef}
className="canvas-minimap"
onPointerDown={onPointerDown}
style={{ width: MINIMAP_W, height: MINIMAP_H }}
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,
}}
/>
))}
{components.map((c) => (
<div
key={c.id}
className="canvas-minimap-component"
style={{
left: c.x * SCALE_X,
top: c.y * SCALE_Y,
}}
/>
))}
</div>
<div
className="canvas-minimap-viewport"
style={{
left: clampedX,
top: clampedY,
width: clampedW,
height: clampedH,
}}
/>
</div>
);
};

View File

@ -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. */}
<CanvasMinimap
pan={pan}
zoom={zoom}
setPan={(p) => {
panRef.current = p;
setPan(p);
}}
components={components}
boards={boards}
viewportRef={canvasRef}
/>
</div>
</div>