perf(web/mazenet): ref-driven pan, memoized children, indexed edge lookup
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<id, node> 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.
This commit is contained in:
@@ -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 { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from '../../icons';
|
||||||
import NetBox from './NetBox';
|
import NetBox from './NetBox';
|
||||||
import NodeCard from './NodeCard';
|
import NodeCard from './NodeCard';
|
||||||
@@ -34,6 +34,8 @@ interface Props {
|
|||||||
sseConnected?: boolean;
|
sseConnected?: boolean;
|
||||||
lastEventAt?: Date | null;
|
lastEventAt?: Date | null;
|
||||||
onSelectService?: (nodeId: string, slug: string) => void;
|
onSelectService?: (nodeId: string, slug: string) => void;
|
||||||
|
panLayerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
gridPatternRef?: React.RefObject<SVGPatternElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtTime = (d: Date) =>
|
const fmtTime = (d: Date) =>
|
||||||
@@ -46,16 +48,26 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
|
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
|
||||||
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
||||||
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
||||||
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService },
|
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService,
|
||||||
|
panLayerRef, gridPatternRef },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
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 absPos = (node: MazeNode) => {
|
||||||
const net = netById.get(node.netId);
|
const net = netById.get(node.netId);
|
||||||
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
|
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 activeNetIds = useMemo(() => {
|
||||||
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
|
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -90,6 +102,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern
|
<pattern
|
||||||
|
ref={gridPatternRef ?? null}
|
||||||
id="maze-grid-pat"
|
id="maze-grid-pat"
|
||||||
x={pan.x}
|
x={pan.x}
|
||||||
y={pan.y}
|
y={pan.y}
|
||||||
@@ -110,6 +123,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={panLayerRef ?? null}
|
||||||
className="maze-pan-layer"
|
className="maze-pan-layer"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
@@ -129,8 +143,8 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
{edges.map((e) => {
|
{edges.map((e) => {
|
||||||
const from = nodes.find((n) => n.id === e.from);
|
const from = nodeById.get(e.from);
|
||||||
const to = nodes.find((n) => n.id === e.to);
|
const to = nodeById.get(e.to);
|
||||||
if (!from || !to) return null;
|
if (!from || !to) return null;
|
||||||
const a = absPos(from); const b = absPos(to);
|
const a = absPos(from); const b = absPos(to);
|
||||||
const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H;
|
const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H;
|
||||||
@@ -175,7 +189,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
dropTarget={dropTargetId === net.id}
|
dropTarget={dropTargetId === net.id}
|
||||||
inactive={inactive}
|
inactive={inactive}
|
||||||
deployed={deployed}
|
deployed={deployed}
|
||||||
onSelect={(id) => setSelection({ type: 'net', id })}
|
onSelect={selectNet}
|
||||||
onHeaderMouseDown={onNetMouseDown}
|
onHeaderMouseDown={onNetMouseDown}
|
||||||
onResizeMouseDown={onNetResizeMouseDown}
|
onResizeMouseDown={onNetResizeMouseDown}
|
||||||
onContextMenu={onNetContextMenu?.(net.id)}
|
onContextMenu={onNetContextMenu?.(net.id)}
|
||||||
@@ -194,7 +208,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
deployed={deployed}
|
deployed={deployed}
|
||||||
dragging={dragging && n.id === selNodeId}
|
dragging={dragging && n.id === selNodeId}
|
||||||
selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null}
|
selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null}
|
||||||
onSelect={(id) => setSelection({ type: 'node', id })}
|
onSelect={selectNode}
|
||||||
onSelectService={onSelectService}
|
onSelectService={onSelectService}
|
||||||
onMouseDown={onNodeMouseDown}
|
onMouseDown={onNodeMouseDown}
|
||||||
onPortMouseDown={onPortMouseDown}
|
onPortMouseDown={onPortMouseDown}
|
||||||
|
|||||||
@@ -664,6 +664,8 @@ const MazeNET: React.FC = () => {
|
|||||||
sseConnected={streamLive}
|
sseConnected={streamLive}
|
||||||
lastEventAt={lastEventAt}
|
lastEventAt={lastEventAt}
|
||||||
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
|
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
|
||||||
|
panLayerRef={interaction.panLayerRef}
|
||||||
|
gridPatternRef={interaction.gridPatternRef}
|
||||||
/>
|
/>
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
|
|||||||
@@ -79,4 +79,4 @@ const NetBox: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetBox;
|
export default React.memo(NetBox);
|
||||||
|
|||||||
@@ -100,4 +100,4 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deplo
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NodeCard;
|
export default React.memo(NodeCard);
|
||||||
|
|||||||
@@ -54,6 +54,37 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
|
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
|
||||||
useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]);
|
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<HTMLDivElement | null>(null);
|
||||||
|
const gridPatternRef = useRef<SVGPatternElement | null>(null);
|
||||||
|
const rafHandle = useRef<number | null>(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<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => {
|
const startPaletteDrag = useCallback((d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => {
|
||||||
setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY });
|
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) return;
|
||||||
|
|
||||||
if (d.type === 'pan') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +329,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
/* Intra-net moves and net/resize drags are cosmetic — never persisted. */
|
/* 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);
|
setDropTargetId(null);
|
||||||
setDrag(null);
|
setDrag(null);
|
||||||
};
|
};
|
||||||
@@ -302,7 +348,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
};
|
};
|
||||||
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, 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
|
/* Wheel zoom anchored at cursor — attached as a native non-passive
|
||||||
* listener so preventDefault() actually stops the page from scrolling
|
* 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 my = e.clientY - o.y;
|
||||||
const wx = (mx - p.x) / z;
|
const wx = (mx - p.x) / z;
|
||||||
const wy = (my - p.y) / 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);
|
setZoom(nz);
|
||||||
setPan({ x: mx - wx * nz, y: my - wy * nz });
|
setPan(np);
|
||||||
|
writeTransform();
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
}, [canvasRef]);
|
}, [canvasRef, writeTransform]);
|
||||||
|
|
||||||
const zoomBy = useCallback((mult: number) => {
|
const zoomBy = useCallback((mult: number) => {
|
||||||
const z = zoomRef.current;
|
const z = zoomRef.current;
|
||||||
@@ -339,9 +395,13 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
const wx = (cx - p.x) / z;
|
const wx = (cx - p.x) / z;
|
||||||
const wy = (cy - p.y) / 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);
|
setZoom(nz);
|
||||||
setPan({ x: cx - wx * nz, y: cy - wy * nz });
|
setPan(np);
|
||||||
}, [canvasRef]);
|
writeTransform();
|
||||||
|
}, [canvasRef, writeTransform]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pan,
|
pan,
|
||||||
@@ -358,5 +418,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef,
|
|||||||
onPortMouseDown,
|
onPortMouseDown,
|
||||||
resetPan,
|
resetPan,
|
||||||
zoomBy,
|
zoomBy,
|
||||||
|
panLayerRef,
|
||||||
|
gridPatternRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user