diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index 00f9f647..3f75de8f 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useMemo } from 'react'; +import React, { forwardRef, useCallback, useMemo } from 'react'; import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from '../../icons'; import NetBox from './NetBox'; import NodeCard from './NodeCard'; @@ -34,6 +34,8 @@ interface Props { sseConnected?: boolean; lastEventAt?: Date | null; onSelectService?: (nodeId: string, slug: string) => void; + panLayerRef?: React.RefObject; + gridPatternRef?: React.RefObject; } const fmtTime = (d: Date) => @@ -46,16 +48,26 @@ const Canvas = forwardRef(function Canvas( { nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw, onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown, onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu, - onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService }, + onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService, + panLayerRef, gridPatternRef }, ref, ) { const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]); + // Pre-indexed node lookup so edge rendering is O(E) instead of + // O(E·N) from the prior `nodes.find(...)` inside the edge loop. + const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const absPos = (node: MazeNode) => { const net = netById.get(node.netId); return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y }; }; + // Stable per-kind selection callbacks so React.memo on children + // (NetBox/NodeCard) can actually short-circuit re-renders instead + // of seeing a fresh closure on every Canvas render. + const selectNet = useCallback((id: string) => setSelection({ type: 'net', id }), [setSelection]); + const selectNode = useCallback((id: string) => setSelection({ type: 'node', id }), [setSelection]); + const activeNetIds = useMemo(() => { const nodeNet = new Map(nodes.map((n) => [n.id, n.netId])); const ids = new Set(); @@ -90,6 +102,7 @@ const Canvas = forwardRef(function Canvas( (function Canvas(
(function Canvas( {edges.map((e) => { - const from = nodes.find((n) => n.id === e.from); - const to = nodes.find((n) => n.id === e.to); + const from = nodeById.get(e.from); + const to = nodeById.get(e.to); if (!from || !to) return null; const a = absPos(from); const b = absPos(to); const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H; @@ -175,7 +189,7 @@ const Canvas = forwardRef(function Canvas( dropTarget={dropTargetId === net.id} inactive={inactive} deployed={deployed} - onSelect={(id) => setSelection({ type: 'net', id })} + onSelect={selectNet} onHeaderMouseDown={onNetMouseDown} onResizeMouseDown={onNetResizeMouseDown} onContextMenu={onNetContextMenu?.(net.id)} @@ -194,7 +208,7 @@ const Canvas = forwardRef(function Canvas( deployed={deployed} dragging={dragging && n.id === selNodeId} selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null} - onSelect={(id) => setSelection({ type: 'node', id })} + onSelect={selectNode} onSelectService={onSelectService} onMouseDown={onNodeMouseDown} onPortMouseDown={onPortMouseDown} diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 2af9689b..5be4e099 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -664,6 +664,8 @@ const MazeNET: React.FC = () => { sseConnected={streamLive} lastEventAt={lastEventAt} onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })} + panLayerRef={interaction.panLayerRef} + gridPatternRef={interaction.gridPatternRef} /> {ctxMenu && ( setCtxMenu(null)} /> diff --git a/decnet_web/src/components/MazeNET/NetBox.tsx b/decnet_web/src/components/MazeNET/NetBox.tsx index ff3bbaec..c8787398 100644 --- a/decnet_web/src/components/MazeNET/NetBox.tsx +++ b/decnet_web/src/components/MazeNET/NetBox.tsx @@ -79,4 +79,4 @@ const NetBox: React.FC = ({ ); }; -export default NetBox; +export default React.memo(NetBox); diff --git a/decnet_web/src/components/MazeNET/NodeCard.tsx b/decnet_web/src/components/MazeNET/NodeCard.tsx index 973dca9d..153feecb 100644 --- a/decnet_web/src/components/MazeNET/NodeCard.tsx +++ b/decnet_web/src/components/MazeNET/NodeCard.tsx @@ -100,4 +100,4 @@ const NodeCard: React.FC = ({ node, absX, absY, selected, dragging, deplo ); }; -export default NodeCard; +export default React.memo(NodeCard); diff --git a/decnet_web/src/components/MazeNET/useMazeInteraction.ts b/decnet_web/src/components/MazeNET/useMazeInteraction.ts index 9833e963..de197fb0 100644 --- a/decnet_web/src/components/MazeNET/useMazeInteraction.ts +++ b/decnet_web/src/components/MazeNET/useMazeInteraction.ts @@ -54,6 +54,37 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]); useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]); + /* DOM refs for the pan/zoom layer and grid pattern. Pan mousemoves + * write transforms here directly via rAF, bypassing React until + * mouseup. This is what keeps a 30-LAN topology from melting the + * browser — React re-rendering hundreds of SVG paths and div cards + * on every mousemove is the dominant cost, and panning doesn't + * mutate any data, only the viewport. */ + const panLayerRef = useRef(null); + const gridPatternRef = useRef(null); + const rafHandle = useRef(null); + + const writeTransform = useCallback(() => { + if (rafHandle.current !== null) return; + rafHandle.current = requestAnimationFrame(() => { + rafHandle.current = null; + const p = panRef.current; + const z = zoomRef.current; + const layer = panLayerRef.current; + if (layer) { + layer.style.transform = `translate(${p.x}px, ${p.y}px) scale(${z})`; + } + const grid = gridPatternRef.current; + if (grid) { + grid.setAttribute('x', String(p.x)); + grid.setAttribute('y', String(p.y)); + const size = String(40 * z); + grid.setAttribute('width', size); + grid.setAttribute('height', size); + } + }); + }, []); + const startPaletteDrag = useCallback((d: Omit, e: React.MouseEvent) => { setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY }); }, []); @@ -167,7 +198,15 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, if (!d) return; if (d.type === 'pan') { - setPan({ x: d.panX + (e.clientX - d.startX), y: d.panY + (e.clientY - d.startY) }); + // Mutate panRef directly and schedule a DOM write. setPan is + // deferred to mouseup so we avoid a full React re-render per + // mousemove. Other reads of panRef (toWorld, context menu, etc.) + // see the live value immediately. + panRef.current = { + x: d.panX + (e.clientX - d.startX), + y: d.panY + (e.clientY - d.startY), + }; + writeTransform(); return; } @@ -290,6 +329,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, /* Intra-net moves and net/resize drags are cosmetic — never persisted. */ } + if (d.type === 'pan') { + // Commit the drag-accumulated pan (written only to panRef during + // the drag) back to React state so anything reading via props + // (status bar, auto-layout, persistence) sees the final value. + setPan(panRef.current); + } + setDropTargetId(null); setDrag(null); }; @@ -302,7 +348,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, }; }, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]); - const resetPan = useCallback(() => { setPan({ x: 0, y: 0 }); setZoom(1); }, []); + const resetPan = useCallback(() => { + panRef.current = { x: 0, y: 0 }; + zoomRef.current = 1; + setPan({ x: 0, y: 0 }); + setZoom(1); + writeTransform(); + }, [writeTransform]); /* Wheel zoom anchored at cursor — attached as a native non-passive * listener so preventDefault() actually stops the page from scrolling @@ -322,12 +374,16 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, const my = e.clientY - o.y; const wx = (mx - p.x) / z; const wy = (my - p.y) / z; + const np = { x: mx - wx * nz, y: my - wy * nz }; + panRef.current = np; + zoomRef.current = nz; setZoom(nz); - setPan({ x: mx - wx * nz, y: my - wy * nz }); + setPan(np); + writeTransform(); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); - }, [canvasRef]); + }, [canvasRef, writeTransform]); const zoomBy = useCallback((mult: number) => { const z = zoomRef.current; @@ -339,9 +395,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, const p = panRef.current; const wx = (cx - p.x) / z; const wy = (cy - p.y) / z; + const np = { x: cx - wx * nz, y: cy - wy * nz }; + panRef.current = np; + zoomRef.current = nz; setZoom(nz); - setPan({ x: cx - wx * nz, y: cy - wy * nz }); - }, [canvasRef]); + setPan(np); + writeTransform(); + }, [canvasRef, writeTransform]); return { pan, @@ -358,5 +418,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPortMouseDown, resetPan, zoomBy, + panLayerRef, + gridPatternRef, }; }