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
20 changes: 18 additions & 2 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4420,7 +4436,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} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined}>
<div ref={containerRef} className="graph-container relative w-full h-full" data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined} data-dots={isDotMode ? '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 @@ -4604,7 +4620,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} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined}>
<div ref={containerRef} className="graph-container relative w-full h-full overflow-hidden select-none" data-quality={qualityTier} data-dense={isDenseGraph ? 'true' : undefined} data-simplify={isSimplified ? 'true' : undefined} data-dots={isDotMode ? 'true' : undefined}>
<svg
ref={svgRef}
className="w-full h-full"
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,15 @@ input[type="date"]:focus {
display: none !important;
}

/* PERF (dot mode): at extreme zoom-out, edges are sub-pixel hairlines that
convey little but cost ~half the painted elements. Hide them so the whole-
graph overview composites only the node cards (the paint-bound limit). Edges
reappear above DOT_SCALE. data-dots is set by InteractiveGraphVisualization. */
.graph-container[data-dots="true"] svg .edge,
.graph-container[data-dots="true"] svg .edge-clickable {
display: none !important;
}

@media (prefers-reduced-motion: reduce) {
.graph-container svg .edge-flowing-forward,
.graph-container svg .edge-flowing-reverse {
Expand Down
18 changes: 16 additions & 2 deletions tests/diagnostics/large-graph-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ test.describe('large-graph baseline profile @geometry', () => {
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;
Expand Down Expand Up @@ -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); });
Expand Down Expand Up @@ -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);
});
}
Expand Down
Loading