feat(web): MazeNET canvas pan + zoom (0.25×–2.5×)

Wheel-to-zoom anchored at the cursor, ZOOM IN/OUT toolbar buttons, and
a live zoom% in the status bar. Pan layer gets transform-origin 0 0 and
a scale(zoom) factor; grid pattern tile scales with zoom; edge SVG is
overflow:visible so long edges don't clip at high zoom. World-space
hit-testing, resize deltas, and palette drops all divide by zoom.
Reset View zeroes pan AND zoom.
This commit is contained in:
2026-04-22 16:40:47 -04:00
parent 1f429cd00e
commit ef60b086ba
3 changed files with 106 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
import React, { forwardRef, useMemo } from 'react';
import { RotateCcw, LayoutGrid } from 'lucide-react';
import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from 'lucide-react';
import NetBox from './NetBox';
import NodeCard from './NodeCard';
import type { Net, MazeNode, Edge } from './types';
@@ -14,6 +14,7 @@ interface Props {
selection: Selection;
setSelection: (s: Selection) => void;
pan: { x: number; y: number };
zoom: number;
dropTargetId: string | null;
dragging: boolean;
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
@@ -28,6 +29,8 @@ interface Props {
onCanvasContextMenu?: (e: React.MouseEvent) => void;
onResetView?: () => void;
onAutoLayout?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
sseConnected?: boolean;
lastEventAt?: Date | null;
onSelectService?: (nodeId: string, slug: string) => void;
@@ -40,10 +43,10 @@ const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
onResetView, onAutoLayout, sseConnected, lastEventAt, onSelectService },
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService },
ref,
) {
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
@@ -86,16 +89,34 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
<div className="maze-grid-bg">
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="maze-grid-pat" x={pan.x} y={pan.y} width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--grid-line)" strokeWidth="1" />
<pattern
id="maze-grid-pat"
x={pan.x}
y={pan.y}
width={40 * zoom}
height={40 * zoom}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${40 * zoom} 0 L 0 0 0 ${40 * zoom}`}
fill="none"
stroke="var(--grid-line)"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
</svg>
</div>
<div className="maze-pan-layer" style={{ transform: `translate(${pan.x}px, ${pan.y}px)` }}>
<svg className="maze-svg">
<div
className="maze-pan-layer"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<svg className="maze-svg" overflow="visible">
<defs>
<marker id="arrow-matrix" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#00ff41" />
@@ -184,10 +205,10 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
</div>
</div>
{(onResetView || onAutoLayout) && (
{(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
<div className="maze-toolbar">
{onResetView && (
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan to origin">
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan + zoom">
<RotateCcw size={11} /> RESET VIEW
</button>
)}
@@ -196,6 +217,16 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
<LayoutGrid size={11} /> AUTO-LAYOUT
</button>
)}
{onZoomOut && (
<button type="button" className="maze-btn ghost small" onClick={onZoomOut} title="Zoom out">
<ZoomOut size={11} />
</button>
)}
{onZoomIn && (
<button type="button" className="maze-btn ghost small" onClick={onZoomIn} title="Zoom in">
<ZoomIn size={11} />
</button>
)}
</div>
)}
@@ -205,6 +236,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
</span>
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
<span className="status-seg">ZOOM: {Math.round(zoom * 100)}%</span>
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
</div>

View File

@@ -583,6 +583,7 @@ const MazeNET: React.FC = () => {
selection={selection}
setSelection={setSelection}
pan={interaction.pan}
zoom={interaction.zoom}
dropTargetId={interaction.dropTargetId}
dragging={interaction.dragging}
edgeDraw={interaction.edgeDraw}
@@ -597,6 +598,8 @@ const MazeNET: React.FC = () => {
onCanvasContextMenu={onCanvasContextMenu}
onResetView={interaction.resetPan}
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
onZoomIn={() => interaction.zoomBy(1.2)}
onZoomOut={() => interaction.zoomBy(1 / 1.2)}
sseConnected={streamLive}
lastEventAt={lastEventAt}
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}

View File

@@ -39,8 +39,12 @@ interface EdgeDraw {
hoverTarget: string | null;
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [drag, setDrag] = useState<Drag>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
@@ -58,10 +62,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
const netsRef = useRef(nets);
const nodesRef = useRef(nodes);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const dragRef = useRef(drag);
useEffect(() => { netsRef.current = nets; }, [nets]);
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
useEffect(() => { panRef.current = pan; }, [pan]);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => { dragRef.current = drag; }, [drag]);
const canvasOriginRef = useRef(() => {
@@ -69,11 +75,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
return { x: r?.left ?? 0, y: r?.top ?? 0 };
});
/* World-space coords from a client event (applies pan inverse). */
/* World-space coords from a client event (applies pan + zoom inverse). */
const toWorld = useCallback((clientX: number, clientY: number) => {
const o = canvasOriginRef.current();
const p = panRef.current;
return { x: clientX - o.x - p.x, y: clientY - o.y - p.y };
const z = zoomRef.current;
return { x: (clientX - o.x - p.x) / z, y: (clientY - o.y - p.y) / z };
}, []);
/* ── Mousedown dispatchers ────────────────────────────── */
@@ -138,8 +145,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
if (ed) {
const o = canvasOriginRef.current();
const p = panRef.current;
const wx = e.clientX - o.x - p.x;
const wy = e.clientY - o.y - p.y;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const hover = nodesRef.current.find((n) => {
if (n.id === ed.fromId) return false;
const parent = netsRef.current.find((nn) => nn.id === n.netId);
@@ -163,7 +171,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
const w = (() => {
const o = canvasOriginRef.current();
const p = panRef.current;
return { x: e.clientX - o.x - p.x, y: e.clientY - o.y - p.y };
const z = zoomRef.current;
return { x: (e.clientX - o.x - p.x) / z, y: (e.clientY - o.y - p.y) / z };
})();
if (d.type === 'net') {
@@ -193,8 +202,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
}
if (d.type === 'resize') {
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
const z = zoomRef.current;
const dx = (e.clientX - d.startX) / z;
const dy = (e.clientY - d.startY) / z;
setNets((prev) => prev.map((n) => {
if (n.id !== d.id) return n;
let { x, y, w: width, h: height } = d.start;
@@ -221,8 +231,9 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
setPaletteDrag(null);
const o = canvasOriginRef.current();
const p = panRef.current;
const wx = e.clientX - o.x - p.x;
const wy = e.clientY - o.y - p.y;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const rect = canvasRef.current?.getBoundingClientRect();
const inside = rect
? e.clientX >= rect.left && e.clientX <= rect.right
@@ -288,10 +299,50 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
};
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
const resetPan = useCallback(() => { setPan({ x: 0, y: 0 }); setZoom(1); }, []);
/* Wheel zoom anchored at cursor — attached as a native non-passive
* listener so preventDefault() actually stops the page from scrolling
* while zooming the canvas. */
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const factor = Math.exp(-e.deltaY * 0.0015);
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor));
if (nz === z) return;
const mx = e.clientX - o.x;
const my = e.clientY - o.y;
const wx = (mx - p.x) / z;
const wy = (my - p.y) / z;
setZoom(nz);
setPan({ x: mx - wx * nz, y: my - wy * nz });
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [canvasRef]);
const zoomBy = useCallback((mult: number) => {
const z = zoomRef.current;
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * mult));
if (nz === z) return;
const rect = canvasRef.current?.getBoundingClientRect();
const cx = (rect?.width ?? 0) / 2;
const cy = (rect?.height ?? 0) / 2;
const p = panRef.current;
const wx = (cx - p.x) / z;
const wy = (cy - p.y) / z;
setZoom(nz);
setPan({ x: cx - wx * nz, y: cy - wy * nz });
}, [canvasRef]);
return {
pan,
zoom,
dropTargetId,
dragging: drag !== null,
edgeDraw,
@@ -303,5 +354,6 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
onNetResizeMouseDown,
onPortMouseDown,
resetPan,
zoomBy,
};
}