From ef60b086ba6b3b74d396f1261bb940b8878e384b Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 16:40:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20MazeNET=20canvas=20pan=20+=20zoom?= =?UTF-8?q?=20(0.25=C3=97=E2=80=932.5=C3=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet_web/src/components/MazeNET/Canvas.tsx | 50 ++++++++++--- decnet_web/src/components/MazeNET/MazeNET.tsx | 3 + .../components/MazeNET/useMazeInteraction.ts | 72 ++++++++++++++++--- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index e118a61f..d008a2f2 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -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(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(function Canvas(
- - + +
-
- +
+ @@ -184,10 +205,10 @@ const Canvas = forwardRef(function Canvas(
- {(onResetView || onAutoLayout) && ( + {(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
{onResetView && ( - )} @@ -196,6 +217,16 @@ const Canvas = forwardRef(function Canvas( AUTO-LAYOUT )} + {onZoomOut && ( + + )} + {onZoomIn && ( + + )}
)} @@ -205,6 +236,7 @@ const Canvas = forwardRef(function Canvas( GRAPH {sseConnected ? 'LIVE' : 'IDLE'} PAN: {Math.round(pan.x)},{Math.round(pan.y)} + ZOOM: {Math.round(zoom * 100)}% AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'} diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index dc055f45..d44c6ed6 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -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 })} diff --git a/decnet_web/src/components/MazeNET/useMazeInteraction.ts b/decnet_web/src/components/MazeNET/useMazeInteraction.ts index 20a39ceb..96f93fcd 100644 --- a/decnet_web/src/components/MazeNET/useMazeInteraction.ts +++ b/decnet_web/src/components/MazeNET/useMazeInteraction.ts @@ -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(null); const [dropTargetId, setDropTargetId] = useState(null); const [edgeDraw, setEdgeDraw] = useState(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, }; }