From be6d86546bfa04a80a4692223344f08ddf8b9808 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Mon, 15 Jun 2026 08:15:00 -0700 Subject: [PATCH] =?UTF-8?q?Perf=20S5:=20extreme-zoom=20"dot=20mode"=20?= =?UTF-8?q?=E2=80=94=20hide=20edges,=20halve=20painted=20elements=20(whole?= =?UTF-8?q?-graph=20pan=2013=E2=86=9230,=20zoom=2010=E2=86=9221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier stages proved the whole-graph view is PAINT-bound: the browser composites every visible SVG element each time the pan/zoom transform changes, and a JS-side handler throttle did nothing (rejected). The only lever left is painting fewer elements. Below DOT_SCALE (0.2) on a dense graph — i.e. the whole-graph "fit" view — edges are sub-pixel hairlines that convey little but are ~half of what's painted after simplify. Dot mode (`data-dots`) hides all edges + their hitboxes via CSS and skips the entire 1400-edge per-tick positioning pass (dotModeRef early-return in updateEdgePositions). Each node is already just its colored card from S4, so the overview becomes a clean card grid. Edges + full detail return as you zoom in past the threshold (verified: fit → 0 edges; zoomed-in → edges + detail restored). This completes the LOD ladder the user asked for ("remove buttons and simplify nodes as we zoom out"): full detail (zoomed in) → card only, no buttons (S4, <0.45) → dots, no edges (S5, <0.2). Measured (Compute Core, 1000n/1400e, serial), whole-graph fit view: painted els ~2400 → 1000 pan FPS ~13 → 30 (HIGH) / 33 (LOW) zoom FPS 10.5 → 21 / 22 drag FPS 3 → 15 / 8 idle FPS 60 (preserved) profiler adds data-dots + paintedEls + a pan-FPS measurement. Verified: web typecheck 0; THE GATE 5/5; node-inspector + hierarchy-navigation green (zoomed-in detail/edges restore correctly). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 20 +++++++++++++++++-- packages/web/src/index.css | 9 +++++++++ tests/diagnostics/large-graph-profile.spec.ts | 18 +++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 482500a7..2e0dd4bf 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -71,6 +71,13 @@ const DENSE_GRAPH_NODE_THRESHOLD = 150; // whole-graph view, where every element is on screen and painted each frame. const SIMPLIFY_SCALE = 0.45; +// Below this (much smaller) scale a dense graph is in "dot mode": edges are +// sub-pixel hairlines and nodes are tiny, so edges are hidden entirely and their +// per-tick positioning skipped. This roughly halves the painted element count +// (edges are ~half of what's left after simplify), which is the only lever that +// helps the paint-bound whole-graph pan/zoom. Edges return when you zoom past it. +const DOT_SCALE = 0.2; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -112,6 +119,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Mirrors isSimplified for the d3 tick closure (which captures stale render // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. const simplifiedRef = useRef(false); + // Dot mode (extreme zoom-out): edges are hidden, so skip their per-tick work. + const dotModeRef = useRef(false); descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); @@ -3567,6 +3576,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: let labelAvoidCounter = 0; const updateEdgePositions = (forceAvoid = false) => { + // Dot mode (extreme zoom-out): all edges/arrows/labels are hidden, so there + // is nothing to position — skip the whole 1400-edge per-tick pass. + if (dotModeRef.current && !forceAvoid) { + return; + } // Border-to-border anchors: the edge starts/ends where the center line // crosses each card's border, not at the buried center. Computed once per // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor @@ -4195,7 +4209,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const hasNodes = nodes.length > 0; const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; + const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE; simplifiedRef.current = isSimplified; + dotModeRef.current = isDotMode; const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4420,7 +4436,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
+
{/* Error message centered in SVG */} @@ -4604,7 +4620,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
+
{ 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 dataDots = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dots') ?? null); + const paintedEls = await page.evaluate(() => Array.from(document.querySelectorAll('.graph-container svg .node-bg, .graph-container svg .edge')).filter((e) => getComputedStyle(e).display !== 'none').length); 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; @@ -96,6 +98,18 @@ test.describe('large-graph baseline profile @geometry', () => { dragFps = Math.round((frames / ((Date.now() - t) / 1000)) * 10) / 10; } + // Pan-interaction FPS: drag the empty top-left background (clear of the + // centered cloud at fit view). This is the paint-bound whole-graph metric. + 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(150, 150); + await page.mouse.down(); + const pt = Date.now(); + let pang = 0; + while (Date.now() - pt < 4000) { pang += 0.5; await page.mouse.move(150 + Math.cos(pang) * 80, 150 + Math.sin(pang) * 80); await page.waitForTimeout(16); } + await page.mouse.up(); + const pframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const panFps = Math.round((pframes / ((Date.now() - pt) / 1000)) * 10) / 10; + // Zoom-interaction FPS: wheel-zoom repeatedly for 4s. await page.mouse.move(960, 540); 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); }); @@ -138,10 +152,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, dataSimplify, paintedDetail, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, dataDots, paintedEls, paintedDetail, renderedNodes, renderedEdges, idleFps, panFps, 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} simplify=${dataSimplify} paintedDetail=${paintedDetail} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} totalSvgEls=${dom.totalSvgEls}`); + console.log(`[profile] ${quality}: simplify=${dataSimplify} dots=${dataDots} paintedEls=${paintedEls} idleFps=${idleFps} panFps=${panFps} zoomFps=${zoomFps} dragFps=${dragFps} zoomInDragFps=${zoomedInDragFps}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); }