From faf15a682e0a5e7b3c99a8d61ee2e4546e419a82 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 23:04:39 -0700 Subject: [PATCH] Perf S2: viewport culling (zoomed-in) + throttled minimap (drag FPS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When zoomed into a large graph, most nodes are off-screen yet still painted every frame — off-screen SVG costs the same to paint as on-screen. Add geometric viewport culling: node groups (and edges with both ends hidden) outside the viewport + margin get display:none, so they are neither laid out nor painted. Do-no-harm gating (learned by measurement): culling is skipped below scale 0.5 (the whole-graph "fit" view, where every node is on screen and a cull pass is pure overhead — an early attempt that culled unconditionally slowed zoom). When zoomed back out below the threshold, everything is revealed once. Culling is only enabled above 200 nodes. Recomputed on a throttle during sim ticks AND on every pan/zoom (the one-shot sim is usually stopped, so the zoom handler is the only thing that can reveal nodes panned back into view). Also throttle the minimap position-dict rebuild (every tick -> every 8th); it doesn't need 60 Hz and was rebuilding a full 1000-entry dict per tick. Measured (Compute Core, 1000n/1400e, HIGH), zoomed in to scale ~1.7 (982 nodes culled): zoomed-in drag FPS 1.2 -> 9 (~8x). Whole-graph fit-view drag/zoom unchanged (do-no-harm); idle still 60 (S1 preserved). The whole-graph view (scale ~0.1, all elements painted) is bound by element count, addressed next by simplified-node LOD. large-graph-profile.spec.ts now also measures a zoomed-in drag + culled count. Verified: web typecheck 0; THE GATE 5/5; hierarchy-navigation green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 76 +++++++++++++++++-- tests/diagnostics/large-graph-profile.spec.ts | 37 ++++++++- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 100e0c93..0e5ffa8e 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3663,8 +3663,63 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const perfMeter = new PerfMeter(240); const driftMeter = new DriftMeter(); let lastPerfReport = 0; + + // Viewport culling (large graphs only). At 1000 nodes the dominant frame + // cost is the browser repainting every on-screen SVG element each time a + // position changes; off-screen elements cost just as much to paint. We hide + // node groups (and edges with both ends hidden) outside the viewport so they + // are neither painted nor laid out. Geometry-only, generous margin, recomputed + // on a throttle during simulation ticks AND on every pan/zoom (the sim is a + // one-shot, so when it has stopped the zoom handler is the only thing that can + // reveal nodes panned back into view). + const cullEnabled = nodes.length > 200; + const CULL_MARGIN_PX = 300; + // Culling only pays off when enough of the graph is actually off-screen, i.e. + // when zoomed IN. At the whole-graph "fit" view every node is visible, so a + // cull pass would be pure overhead (it even slowed zoom). Below this scale we + // skip culling and, if we had culled, reveal everything once. + const CULL_MIN_SCALE = 0.5; + let cullCounter = 0; + let cullActive = false; + const clearCull = () => { + nodeElements.style('display', null); + linkElements.style('display', null); + clickableEdges.style('display', null); + arrowElements.style('display', null); + edgeLabelGroups.style('display', null); + cullActive = false; + }; + const applyViewportCull = () => { + const svgEl = svg.node(); + if (!svgEl) return; + const t = d3.zoomTransform(svgEl); + if (t.k < CULL_MIN_SCALE) { + if (cullActive) clearCull(); + return; + } + cullActive = true; + const minGX = (-CULL_MARGIN_PX - t.x) / t.k; + const maxGX = (width + CULL_MARGIN_PX - t.x) / t.k; + const minGY = (-CULL_MARGIN_PX - t.y) / t.k; + const maxGY = (height + CULL_MARGIN_PX - t.y) / t.k; + nodeElements.style('display', (d: any) => { + const x = d.x ?? 0; + const y = d.y ?? 0; + const visible = x >= minGX && x <= maxGX && y >= minGY && y <= maxGY; + d._culled = !visible; + return visible ? null : 'none'; + }); + const edgeDisplay = (d: any) => (d.source?._culled && d.target?._culled ? 'none' : null); + linkElements.style('display', edgeDisplay); + clickableEdges.style('display', edgeDisplay); + arrowElements.style('display', edgeDisplay); + edgeLabelGroups.style('display', edgeDisplay); + }; + simulation.on('tick', () => { const tickStart = performance.now(); + cullCounter++; + if (cullEnabled && cullCounter % 5 === 0) applyViewportCull(); // 1) Nodes first nodeElements @@ -3695,8 +3750,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: } // Update mini-map with current node positions (live simulation objects — - // the React-state nodes are different objects since the identity merge) - if ((window as any).updateMiniMapPositions) { + // the React-state nodes are different objects since the identity merge). + // Throttled: rebuilding a full positions dict for every node on every tick + // was pure overhead at scale; the minimap doesn't need 60 Hz updates. + if (cullCounter % 8 === 0 && (window as any).updateMiniMapPositions) { const simNodesForMap = simulation.nodes() as any[]; if (simNodesForMap.length > 0) { const positions: {[key: string]: {x: number, y: number}} = {}; @@ -3746,12 +3803,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Update zoom with LOD updates zoom.on('zoom', (event) => { g.attr('transform', event.transform); - setCurrentTransform({ - x: event.transform.x, - y: event.transform.y, - scale: event.transform.k + setCurrentTransform({ + x: event.transform.x, + y: event.transform.y, + scale: event.transform.k }); - + + // Re-cull on pan/zoom. The one-shot sim is usually stopped during pan, so + // this is the only thing that reveals nodes panned back into view (and + // hides ones panned out) — and it keeps paint bounded while panning. + if (cullEnabled) applyViewportCull(); + // Update mini-map viewport if ((window as any).updateMiniMapViewport) { const viewportUpdate = { diff --git a/tests/diagnostics/large-graph-profile.spec.ts b/tests/diagnostics/large-graph-profile.spec.ts index 427befd1..0964a396 100644 --- a/tests/diagnostics/large-graph-profile.spec.ts +++ b/tests/diagnostics/large-graph-profile.spec.ts @@ -98,10 +98,43 @@ test.describe('large-graph baseline profile @geometry', () => { const zframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); const zoomFps = Math.round((zframes / ((Date.now() - zt) / 1000)) * 10) / 10; - const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, dom }; + // Zoomed-IN drag: zoom in hard so most of the graph is off-screen, then + // drag. This is where viewport culling should help (the fit-view drag above + // keeps every node on screen, so culling can't help there). + await page.mouse.move(960, 540); + for (let i = 0; i < 10; i++) { await page.mouse.wheel(0, 200); await page.waitForTimeout(60); } + await page.waitForTimeout(500); + const zoomState = await page.evaluate(() => { + const g = document.querySelector('.graph-container svg g'); + const tr = g?.getAttribute('transform') ?? ''; + const m = tr.match(/scale\(([0-9.]+)\)/); + const nodes = Array.from(document.querySelectorAll('.graph-container svg .node')); + const hidden = nodes.filter((n) => getComputedStyle(n).display === 'none').length; + return { transform: tr.slice(0, 60), scale: m ? parseFloat(m[1]) : null, hidden, total: nodes.length }; + }); + const culledHidden = zoomState.hidden; + // eslint-disable-next-line no-console + console.log(`[profile] ${quality} zoomState: scale=${zoomState.scale} hidden=${zoomState.hidden}/${zoomState.total} tr="${zoomState.transform}"`); + const zinBox = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return { x: 960, y: 540 }; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(zinBox.x, zinBox.y); + await page.mouse.down(); + const zt2 = Date.now(); + let aa = 0; + while (Date.now() - zt2 < 4000) { aa += 0.6; await page.mouse.move(zinBox.x + Math.cos(aa) * 60, zinBox.y + Math.sin(aa) * 45); await page.waitForTimeout(110); } + await page.mouse.up(); + 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 }; 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} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls} blurOrFilterEls=${dom.blurOrFilterEls}`); + 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}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); }