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:
commit
a4893a0da7
|
|
@ -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