From 207f7916842fe3ff609a9a9086c4eb9fe006ae57 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 18:48:05 -0400 Subject: [PATCH] perf(web/mazenet): ref-driven pan, memoized children, indexed edge lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pan/zoom previously drove a full Canvas re-render on every mousemove via setPan() — at 30 LANs that's ~1000 SVG paths and div cards re-evaluating 60 times a second while you drag. The browser screamed. Three fixes, one surgical pass: 1. Pan drag writes the translate/scale transform directly to the pan-layer DOM ref inside requestAnimationFrame; setPan is deferred to mouseup. Grid pattern attributes (x/y/width/height) get the same treatment so the backdrop stays glued to the canvas content. Wheel zoom, resetPan, and zoomBy also sync refs + fire a write so React-driven changes land in one frame. 2. Edge rendering swaps the nodes.find() inside .map() for a Map built once per render — O(E) instead of O(E·N). NetBox + NodeCard are now wrapped in React.memo; Canvas hoists the setSelection closures into useCallback so memo can actually short-circuit instead of seeing a fresh prop every render. 3. Drag-a-single-node still mutates state and re-renders, but now only the moved node rerenders — the other 89 skip via memo. Everything that reads panRef.current (toWorld, context menu, drop targeting) still sees the live value during drag because we mutate the ref synchronously on each mousemove; only React state is lazy. --- decnet_web/src/components/MazeNET/Canvas.tsx | 26 +++++-- decnet_web/src/components/MazeNET/MazeNET.tsx | 2 + decnet_web/src/components/MazeNET/NetBox.tsx | 2 +- .../src/components/MazeNET/NodeCard.tsx | 2 +- .../components/MazeNET/useMazeInteraction.ts | 74 +++++++++++++++++-- 5 files changed, 92 insertions(+), 14 deletions(-) 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, }; }