From c7fa88e91ba9e3b24fee55fe4397d6af9e27f8d1 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 23:16:56 -0700 Subject: [PATCH] =?UTF-8?q?Perf=20S3:=20simplified-node=20LOD=20when=20zoo?= =?UTF-8?q?med=20out=20(whole-graph=20zoom=20FPS=203.5=E2=86=9210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the whole-graph "fit" view (scale ~0.1) every node's ~40 SVG sub-elements are on screen and painted each frame, and the per-tick edge pass positions 1400 arrows + labels — even though none of it is legible at that zoom. That's what makes looking at the entire graph sluggish. Add a simplified LOD: on a dense graph below SIMPLIFY_SCALE (0.45) the container gets data-simplify, and CSS hides per-node detail (title bar, type/title/desc text, status & priority bars/icons/labels, edit/relationship/descend icons) plus arrows and edge labels outright (display:none — opacity:0 still paints). Each node renders as just its colored card; edges stay so structure is legible. updateEdgePositions also skips the now-hidden arrow + label positioning per tick (via simplifiedRef), removing the bulk of the remaining per-tick cost. Zooming back in past the threshold restores full detail (and the one-shot settle pass still runs so labels are correct when shown). Measured (Compute Core, 1000n/1400e, HIGH), whole-graph fit view: zoom FPS 3.5 → 10.5 (paintedDetail 4000 → 0) drag FPS 1.2 → 4.4 idle FPS 60 → 60 (S1 preserved) Numbers are from a SERIAL profiler run (--workers=1); running the two quality tests in parallel thrashed the CPU and produced contradictory FPS — the spec is now mode:'serial'. Verified: web typecheck 0; THE GATE 5/5; node-inspector green (zoomed-in detail restores correctly). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 24 ++++++++++++-- packages/web/src/index.css | 33 +++++++++++++++++++ tests/diagnostics/large-graph-profile.spec.ts | 13 ++++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 0e5ffa8e..482500a7 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -65,6 +65,12 @@ const LOD_THRESHOLDS = { // filtered layers each frame collapses FPS. Below it, the full aesthetic stays. const DENSE_GRAPH_NODE_THRESHOLD = 150; +// Below this zoom scale on a dense graph, per-node detail (text, icons, status/ +// priority bars) is unreadable, so it is hidden outright (data-simplify) — each +// node renders as just its colored card. This is the dominant win for the +// whole-graph view, where every element is on screen and painted each frame. +const SIMPLIFY_SCALE = 0.45; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -103,6 +109,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // descendInto from context isn't memoized; hold the latest in a ref so the // D3-bound node click handler can call it without re-binding every render. const descendIntoRef = useRef(descendInto); + // Mirrors isSimplified for the d3 tick closure (which captures stale render + // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. + const simplifiedRef = useRef(false); descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); @@ -3583,6 +3592,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: .attr('x2', (d: any) => d._ep.x2) .attr('y2', (d: any) => d._ep.y2); + // Simplified (dense + zoomed out): arrows and edge labels are hidden + // (data-simplify CSS), so skip their per-tick positioning entirely — at + // 1400 edges that arrow transform + label placement pass is the bulk of + // the remaining per-tick cost in the whole-graph view. forceAvoid (the + // one-shot settle pass) still runs so labels are correct when you zoom in. + if (simplifiedRef.current && !forceAvoid) { + return; + } + // Arrow sits at the TARGET border, pointing into the node. arrowElements .attr('transform', (d: any) => { @@ -4176,6 +4194,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // a graph change. We wait briefly for the one-shot layout to settle, then fit. const hasNodes = nodes.length > 0; const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; + const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; + simplifiedRef.current = isSimplified; const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4400,7 +4420,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
+
{/* Error message centered in SVG */} @@ -4584,7 +4604,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
+
{ } test.describe('large-graph baseline profile @geometry', () => { - test.describe.configure({ timeout: 180_000 }); + // Serial: two browsers rendering a 1000-node graph at once thrash the CPU and + // make the FPS numbers meaningless. One at a time. + test.describe.configure({ timeout: 180_000, mode: 'serial' }); for (const quality of ['HIGH', 'LOW']) { test(`compute-core profile @${quality}`, async ({ page }) => { @@ -50,6 +52,11 @@ test.describe('large-graph baseline profile @geometry', () => { const renderedNodes = await page.locator('.graph-container svg .node').count(); const renderedEdges = await page.locator('.graph-container svg .edge').count(); const dataDense = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dense') ?? null); + const dataSimplify = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-simplify') ?? null); + const paintedDetail = await page.evaluate(() => { + const sel = '.graph-container svg .node-title-bar, .graph-container svg .status-progress-bg, .graph-container svg .priority-progress-bg, .graph-container svg .node-type-text'; + return Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; + }); // DOM weight: total SVG elements, per-node element count, CSS filter usage. const dom = await page.evaluate(() => { @@ -131,10 +138,10 @@ test.describe('large-graph baseline profile @geometry', () => { const zinFrames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); const zoomedInDragFps = Math.round((zinFrames / ((Date.now() - zt2) / 1000)) * 10) / 10; - const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, paintedDetail, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); // eslint-disable-next-line no-console - console.log(`[profile] ${quality}: dense=${dataDense} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls}`); + console.log(`[profile] ${quality}: dense=${dataDense} simplify=${dataSimplify} paintedDetail=${paintedDetail} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} totalSvgEls=${dom.totalSvgEls}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); }