diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 7b5caf13..100e0c93 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -59,6 +59,12 @@ const LOD_THRESHOLDS = { CLOSE: 1.0, }; +// Above this node count a graph is "dense": the continuous living-graph effects +// (breathing/ache/flow animations + per-node drop-shadow halos) are gated off +// via data-dense regardless of the quality tier, because repainting that many +// filtered layers each frame collapses FPS. Below it, the full aesthetic stays. +const DENSE_GRAPH_NODE_THRESHOLD = 150; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -4107,6 +4113,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // hasNodes only and restored one global transform, so it never recentered on // 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 currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4331,7 +4338,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
+
{/* Error message centered in SVG */} @@ -4515,7 +4522,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
+
{ + localStorage.setItem('currentGraphId', g); + localStorage.setItem('graphdone.quality.override', q); + }, + { g: gid, q: quality } + ); + await page.reload(); + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 60_000 }).catch(() => {}); + await page.waitForTimeout(8000); // let one-shot physics settle +} + +async function rafFps(page: Page, ms: number): Promise { + 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.waitForTimeout(ms); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + return Math.round((frames / (ms / 1000)) * 10) / 10; +} + +test.describe('large-graph baseline profile @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + + for (const quality of ['HIGH', 'LOW']) { + test(`compute-core profile @${quality}`, async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openGraph(page, COMPUTE_GRAPH_ID, quality); + + 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); + + // DOM weight: total SVG elements, per-node element count, CSS filter usage. + const dom = await page.evaluate(() => { + const svg = document.querySelector('.graph-container svg'); + const all = svg ? svg.querySelectorAll('*').length : 0; + const nodes = document.querySelectorAll('.graph-container svg .node').length; + const texts = svg ? svg.querySelectorAll('text').length : 0; + const fos = svg ? svg.querySelectorAll('foreignObject').length : 0; + const filtered = document.querySelectorAll('.graph-container [style*="filter"], .graph-container [filter]').length; + const blur = Array.from(document.querySelectorAll('.graph-container *')).filter((e) => { + const s = getComputedStyle(e as Element); + return s.backdropFilter !== 'none' || s.filter !== 'none'; + }).length; + return { totalSvgEls: all, nodes, texts, foreignObjects: fos, inlineFilterEls: filtered, blurOrFilterEls: blur, perNodeEls: nodes ? Math.round(all / nodes) : 0 }; + }); + + // Idle FPS (nothing happening, physics stopped). + const idleFps = await rafFps(page, 3000); + + // Drag-interaction FPS: hold a node and move it for 5s (sim ticks while dragging). + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + let dragFps = -1; + if (box) { + 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(box.x, box.y); + await page.mouse.down(); + const t = Date.now(); + let a = 0; + while (Date.now() - t < 5000) { a += 0.6; await page.mouse.move(box.x + Math.cos(a) * 80, box.y + Math.sin(a) * 60); await page.waitForTimeout(110); } + await page.mouse.up(); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + dragFps = Math.round((frames / ((Date.now() - t) / 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); }); + const zt = Date.now(); + let dir = -1; + while (Date.now() - zt < 4000) { dir = -dir; await page.mouse.wheel(0, dir * 120); await page.waitForTimeout(60); } + 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 }; + 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}`); + expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); + }); + } +});