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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user