feat(canvas): minimap with draggable viewport in the bottom-right corner
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.
This commit is contained in:
parent
83f11f839a
commit
dcf98fd0e5
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue