perf(web/mazenet): auto-disable edge flow animation above 60 edges

The .maze-edge-dash CSS animation invalidates each path's bounding
box every frame. Inter-LAN paths span the viewport so invalidations
overlap, and past ~60 edges the compositor spends every frame
repainting — the dominant cost on the 12+ LAN screenshot, even
dwarfing pan-drag overhead.

Drop the animation class when edges.length > 60. Edges stay fully
visible and traffic-tinted, just static. A MOTION: OFF segment in
the status bar surfaces the auto-disable so it doesn't look like a
broken animation.

Threshold is a constant in Canvas.tsx; if it needs to become a
user toggle later, lift it to state + localStorage in one place.
This commit is contained in:
2026-04-24 19:01:25 -04:00
parent f3408d5e62
commit 9bed930497

View File

@@ -68,6 +68,15 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
const selectNet = useCallback((id: string) => setSelection({ type: 'net', id }), [setSelection]);
const selectNode = useCallback((id: string) => setSelection({ type: 'node', id }), [setSelection]);
// Flowing-dash edge animation is the single most expensive thing
// on the canvas — each animated <path> invalidates its bounding
// box every frame, and inter-LAN paths are long so the invalidated
// rects overlap most of the viewport. Past ~60 edges the compositor
// spends every frame repainting. Drop the animation class above
// the threshold; edges stay fully visible, just static.
const ANIMATE_EDGE_LIMIT = 60;
const animateEdges = edges.length <= ANIMATE_EDGE_LIMIT;
const activeNetIds = useMemo(() => {
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
const ids = new Set<string>();
@@ -158,7 +167,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
<g key={e.id} style={{ pointerEvents: 'auto' }}
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}
onContextMenu={onEdgeContextMenu?.(e.id)}>
<path d={d} className={`maze-edge ${klass} maze-edge-dash`} markerEnd={`url(#${marker})`}
<path d={d} className={`maze-edge ${klass} ${animateEdges ? 'maze-edge-dash' : ''}`} markerEnd={`url(#${marker})`}
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
{e.label && (
@@ -252,6 +261,11 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
<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>
{!animateEdges && (
<span className="status-seg" title={`Flow animation auto-disabled above ${ANIMATE_EDGE_LIMIT} edges (${edges.length} active) to keep the canvas responsive.`}>
MOTION: OFF
</span>
)}
</div>
<div className="maze-legend">