Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4331,7 +4338,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
const isNetworkError = errorMessage.includes('Cannot connect');

return (
<div ref={containerRef} className="graph-container relative w-full h-full" data-quality={qualityTier}>
<div ref={containerRef} className="graph-container relative w-full h-full" data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined}>
<svg ref={svgRef} className="w-full h-full">
{/* Error message centered in SVG */}
<foreignObject x="20%" y="30%" width="60%" height="40%">
Expand Down Expand Up @@ -4515,7 +4522,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:


return (
<div ref={containerRef} className="graph-container relative w-full h-full overflow-hidden select-none" data-quality={qualityTier}>
<div ref={containerRef} className="graph-container relative w-full h-full overflow-hidden select-none" data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined}>
<svg
ref={svgRef}
className="w-full h-full"
Expand Down
23 changes: 23 additions & 0 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,29 @@ input[type="date"]:focus {
animation: none;
}

/* PERF (large graphs): independent of the quality tier, a graph with many
nodes strips the continuous per-node/edge effects. The breathing/ache/flow
animations and drop-shadow halos force a repaint of every filtered layer on
every frame, which collapses FPS at scale (measured: ~7 idle FPS at 1000
nodes vs ~60 with these off, identical DOM). Small graphs keep the full
living-graph aesthetic. data-dense is set by InteractiveGraphVisualization
above DENSE_GRAPH_NODE_THRESHOLD nodes. */
.graph-container[data-dense="true"] svg .node-breathing,
.graph-container[data-dense="true"] svg .node-stuck {
animation: none;
}

.graph-container[data-dense="true"] svg .node-bg,
.graph-container[data-dense="true"] svg .node-stuck {
filter: none !important;
}

.graph-container[data-dense="true"] svg .edge-flowing-forward,
.graph-container[data-dense="true"] svg .edge-flowing-reverse {
stroke-dasharray: none;
animation: none;
}

@media (prefers-reduced-motion: reduce) {
.graph-container svg .edge-flowing-forward,
.graph-container svg .edge-flowing-reverse {
Expand Down
108 changes: 108 additions & 0 deletions tests/diagnostics/large-graph-profile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { test, expect, Page } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { login, TEST_USERS } from '../helpers/auth';

/**
* BASELINE profiler for the Compute Core (1000-node) example graph the user
* called out as low-FPS. Report-only: measures idle FPS, drag-interaction FPS,
* zoom-interaction FPS, and a DOM-weight breakdown, plus where per-frame time
* goes (long-task sampling). Writes one JSON under test-artifacts/large-graph/.
*/
const COMPUTE_GRAPH_ID = 'subgraph-compute-shared';
const OUT = path.resolve(process.cwd(), 'test-artifacts/large-graph');

async function openGraph(page: Page, gid: string, quality: string) {
await page.evaluate(
({ g, q }) => {
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<number> {
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);
});
}
});
Loading