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
76 changes: 69 additions & 7 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}} = {};
Expand Down Expand Up @@ -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 = {
Expand Down
37 changes: 35 additions & 2 deletions tests/diagnostics/large-graph-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
Loading