From 8e445e05c863de73af9edf1ce8478db54f69010a Mon Sep 17 00:00:00 2001 From: Hugo Gu Date: Mon, 25 May 2026 21:38:42 +0800 Subject: [PATCH] perf(web): add adaptive large-graph guards to sequential layout physics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For graphs with N > 5 000 nodes, each rAF frame of runTreeLayout was doing O(N log N) sort + O(N × k) repulsion pair comparisons (k ≈ 2 400 for a 20 K-node graph spread across 1 080 px at range 130). At that scale each frame took hundreds of ms, making the canvas appear completely frozen even though the physics loop was still running. Fix mirrors the circles layout adaptive strategy: N > 5 000 (large): - Skip repulsion pass (O(N × k) → 0) - Skip spread-force sort (O(N log N) → 0) - Velocity cap raised to ±12 / ±6 px so nodes cover ground faster - Damping 0.58, 1 sim step/frame, 30 s max duration - Looser early-stop thresholds (max v 0.05, avg v 0.03, active 2 %) N > 1 500 (medium): - Velocity cap raised to ±6 / ±3 px - 24 s max duration - Repulsion and spread still active N ≤ 1 500 (small): - Unchanged behaviour (velocity ±3/±2, 18 s, all forces active) Layer gravity (O(N)) and edge springs (O(E)) run for all graph sizes — they provide the structural pull that replaces repulsion at large N. Co-authored-by: Claude AI-model: claude-sonnet-4-6 --- gitnexus-web/src/hooks/useSigma.ts | 152 +++++++++++++++++------------ 1 file changed, 89 insertions(+), 63 deletions(-) diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 0bf48c5f4..f8a9bead8 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -772,6 +772,28 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { setIsLayoutRunning(true); + // Adaptive tuning — mirrors the circles layout strategy. + // The repulsion pass is O(N × k) after sorting; for large graphs k + // can be thousands, making each frame multi-hundred ms → apparent freeze. + const treeNodeCount = graph.order; + const treeIsLarge = treeNodeCount > 5000; + const treeIsMedium = treeNodeCount > 1500; + const treeUseRepulsion = !treeIsLarge; // skip O(N×k) repulsion for large graphs + const treeUseSpread = !treeIsLarge; // skip O(N log N) spread sort for large graphs + const treeDamping = treeIsLarge ? 0.58 : 0.62; + const treeVelocityCapX = treeIsLarge ? 12 : treeIsMedium ? 6 : 3; + const treeVelocityCapY = treeIsLarge ? 6 : treeIsMedium ? 3 : 2; + const treeMaxSimSteps = treeIsLarge ? 1 : 2; + const treeEffectiveMaxDuration = treeIsLarge + ? 30000 + : treeIsMedium + ? 24000 + : TREE_LAYOUT_MAX_DURATION; + const treeStopMaxVelocity = treeIsLarge ? 0.05 : 0.022; + const treeStopAvgVelocity = treeIsLarge ? 0.03 : 0.016; + const treeStopActiveNodeFraction = treeIsLarge ? 0.02 : 0.008; + const treeStopStabilityFrames = treeIsLarge ? 20 : TREE_LAYOUT_STABILITY_FRAMES; + const step = (timestamp: number) => { if (!graphRef.current || graphRef.current !== graph) { stopTreeLayout(false); @@ -798,7 +820,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { } const simulationSteps = Math.min( - 2, + treeMaxSimSteps, Math.floor(treeAccumulatorRef.current / TREE_TARGET_FRAME_MS), ); treeAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; @@ -868,48 +890,49 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { // 3. Node repulsion in 2D: all pairs within range (cross-layer included) // Sort by X for O(n·k) early-exit: once dx > range, all further pairs are too far. // - // Cross-layer pairs use reduced repulsion (25 % of same-layer strength). - // Full-strength cross-layer repulsion was the main barrier preventing - // nodes from moving horizontally to align with their parents — a node - // in Layer 2 would block a Layer 1 File from moving toward its Package. - const nodeList = graph.nodes().map((id) => { - const a = graph.getNodeAttributes(id); - return { id, x: a.x, y: a.y, size: a.size ?? 6, layer: a.treeLayer ?? 0 }; - }); - nodeList.sort((a, b) => a.x - b.x); + // Skipped for large graphs (N > 5 000) — sorting + pair comparisons make each + // frame take hundreds of ms, leaving the canvas apparently frozen. Layer gravity + // and edge springs provide sufficient structure without repulsion. + if (treeUseRepulsion) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, layer: a.treeLayer ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); - for (let i = 0; i < nodeList.length; i++) { - const nodeA = nodeList[i]; - for (let j = i + 1; j < nodeList.length; j++) { - const nodeB = nodeList[j]; - const dx = nodeB.x - nodeA.x; - if (dx > TREE_REPULSION_RANGE) break; // X-sorted: all further pairs are also too far + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > TREE_REPULSION_RANGE) break; // X-sorted: all further pairs are also too far - const dy = nodeB.y - nodeA.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - if (dist > TREE_REPULSION_RANGE) continue; + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + if (dist > TREE_REPULSION_RANGE) continue; - const sameLayer = nodeA.layer === nodeB.layer; - // Same-layer repulsion reduced from 160→100 so the stronger X spring - // (0.30) can now overcome collective repulsion from 3-4 nearby nodes. - // Cross-layer kept low (28) so intermediate-layer nodes don't block - // parent-child X alignment. - const repulsionStrength = sameLayer ? 100 : 28; - const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); - let repulsion = - (1 / (dist + 8) - 1 / (TREE_REPULSION_RANGE + 8)) * repulsionStrength * dtScale; - if (dist < minGap && sameLayer) { - repulsion += (minGap - dist) * 0.1 * dtScale; + const sameLayer = nodeA.layer === nodeB.layer; + // Same-layer repulsion reduced from 160→100 so the stronger X spring + // (0.30) can now overcome collective repulsion from 3-4 nearby nodes. + // Cross-layer kept low (28) so intermediate-layer nodes don't block + // parent-child X alignment. + const repulsionStrength = sameLayer ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (dist + 8) - 1 / (TREE_REPULSION_RANGE + 8)) * repulsionStrength * dtScale; + if (dist < minGap && sameLayer) { + repulsion += (minGap - dist) * 0.1 * dtScale; + } + if (repulsion <= 0) continue; + + const fx = (dx / dist) * repulsion; + const fy = (dy / dist) * repulsion; + + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); } - if (repulsion <= 0) continue; - - const fx = (dx / dist) * repulsion; - const fy = (dy / dist) * repulsion; - - forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); - forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); - forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); - forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); } } @@ -921,21 +944,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { // (force ≈ 1–2 units) resist and stay clustered; nodes without a // strong spring anchor (isolated or same-layer-only) drift to fill gaps. // Net effect: dense centre spreads outward, sparse edges fill in. - const spreadByLayer = new Map>(); - graph.forEachNode((nodeId, attrs) => { - const layer = attrs.treeLayer ?? 0; - if (!spreadByLayer.has(layer)) spreadByLayer.set(layer, []); - spreadByLayer.get(layer)!.push({ id: nodeId, x: attrs.x }); - }); - for (const [, layerNodes] of spreadByLayer) { - if (layerNodes.length < 2) continue; - layerNodes.sort((a, b) => a.x - b.x); - const count = layerNodes.length; - const spacing = (TREE_MAX_X * 2) / count; - for (let i = 0; i < count; i++) { - const { id, x } = layerNodes[i]; - const idealX = -TREE_MAX_X + (i + 0.5) * spacing; - forceX.set(id, (forceX.get(id) ?? 0) + (idealX - x) * TREE_SPREAD_STRENGTH * dtScale); + // Skipped for large graphs — per-layer sort is O(N log N) per frame. + if (treeUseSpread) { + const spreadByLayer = new Map>(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + if (!spreadByLayer.has(layer)) spreadByLayer.set(layer, []); + spreadByLayer.get(layer)!.push({ id: nodeId, x: attrs.x }); + }); + for (const [, layerNodes] of spreadByLayer) { + if (layerNodes.length < 2) continue; + layerNodes.sort((a, b) => a.x - b.x); + const count = layerNodes.length; + const spacing = (TREE_MAX_X * 2) / count; + for (let i = 0; i < count; i++) { + const { id, x } = layerNodes[i]; + const idealX = -TREE_MAX_X + (i + 0.5) * spacing; + forceX.set(id, (forceX.get(id) ?? 0) + (idealX - x) * TREE_SPREAD_STRENGTH * dtScale); + } } } @@ -966,16 +992,16 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const normY = Math.min(1, Math.abs(yOffset) / TREE_LAYER_BAND_HALF); const resistY = 1 + normY * normY * TREE_LAYER_BOUNDARY_RESISTANCE; - const rawVx = (vx0 + fx / resistX) * 0.62; - const rawVy = (vy0 + fy / resistY) * 0.62; + const rawVx = (vx0 + fx / resistX) * treeDamping; + const rawVy = (vy0 + fy / resistY) * treeDamping; const newVx = Math.abs(fx) < TREE_FORCE_DEADZONE && Math.abs(rawVx) < TREE_VELOCITY_DEADZONE ? 0 - : clamp(rawVx, -3, 3); + : clamp(rawVx, -treeVelocityCapX, treeVelocityCapX); const newVy = Math.abs(fy) < TREE_FORCE_DEADZONE && Math.abs(rawVy) < TREE_VELOCITY_DEADZONE ? 0 - : clamp(rawVy, -2, 2); + : clamp(rawVy, -treeVelocityCapY, treeVelocityCapY); treeVelocityRef.current.set(nodeId, newVx); treeVelocityYRef.current.set(nodeId, newVy); @@ -1011,9 +1037,9 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { if ( elapsed >= TREE_LAYOUT_MIN_DURATION && - maxVelocity < 0.022 && - activeNodes <= Math.max(2, Math.floor(graph.order * 0.008)) && - averageVelocity < 0.016 + maxVelocity < treeStopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * treeStopActiveNodeFraction)) && + averageVelocity < treeStopAvgVelocity ) { treeStableFramesRef.current += 1; } else { @@ -1021,8 +1047,8 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { } if ( - treeStableFramesRef.current >= TREE_LAYOUT_STABILITY_FRAMES || - elapsed >= TREE_LAYOUT_MAX_DURATION + treeStableFramesRef.current >= treeStopStabilityFrames || + elapsed >= treeEffectiveMaxDuration ) { stopTreeLayout(true); return;