From 7756043b97d622bef16a6fe71f04d07b1acc06e8 Mon Sep 17 00:00:00 2001 From: youzini Date: Thu, 4 Jun 2026 13:44:12 +0000 Subject: [PATCH] feat(graph-ui): animate bounded graph layout --- tests/graph-renderer-guardrails.mjs | 162 ++++++++++ ui/graph-native-bridge.js | 15 + ui/graph-renderer.js | 461 +++++++++++++++++++++++----- 3 files changed, 567 insertions(+), 71 deletions(-) diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs index 26f1fa6..a62fe7c 100644 --- a/tests/graph-renderer-guardrails.mjs +++ b/tests/graph-renderer-guardrails.mjs @@ -47,6 +47,20 @@ function flushNextRaf(ms = 16) { return true; } +function flushRafsUntilIdle({ maxFrames = 80, ms = 16 } = {}) { + let frames = 0; + while (flushNextRaf(ms)) { + frames += 1; + assert.ok(frames <= maxFrames, `RAF loop exceeded ${maxFrames} frames`); + } + return frames; +} + +function assertNoPendingRafOrTimers(reason) { + assert.equal(rafCallbacks.size, 0, `${reason}: pending RAF callbacks remain`); + assert.equal(timerCallbacks.size, 0, `${reason}: pending timers remain`); +} + function advanceMockTime(ms = 0) { mockNow += ms; let ran = false; @@ -193,6 +207,76 @@ function assertRendererNodesInsideRegions(renderer) { } } +function createAnimatedLayoutRenderer({ reducedMotion = false, configOff = false } = {}) { + const previousMatchMedia = globalThis.window.matchMedia; + globalThis.window.matchMedia = () => ({ + matches: Boolean(reducedMotion), + addEventListener() {}, + removeEventListener() {}, + }); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { + graphUseNativeLayout: false, + graphNativeForceDisable: true, + graphAnimatedLayout: !configOff, + graphLayoutAnimation: !configOff, + graphLayoutAnimationEnabled: !configOff, + }, + layoutConfig: { + neuralIterations: 24, + animatedLayout: !configOff, + layoutAnimation: !configOff, + layoutAnimationEnabled: !configOff, + layoutAnimationDurationMs: 64, + layoutAnimationMaxFrames: 8, + layoutAnimationIterationsPerFrame: 3, + layoutAnimationMinInitialIterations: 4, + }, + }); + return { + renderer, + restoreMatchMedia: () => { + globalThis.window.matchMedia = previousMatchMedia; + }, + }; +} + +function readLayoutAnimationDiagnostics(renderer) { + const diagnostics = renderer.getLastLayoutDiagnostics?.() || null; + const animationDiagnostics = diagnostics?.layoutAnimation + ?? diagnostics?.layoutAnimationDiagnostics + ?? diagnostics?.animation + ?? null; + return { diagnostics, animationDiagnostics }; +} + +function assertLayoutAnimationNotRunning(renderer, reason) { + const { diagnostics, animationDiagnostics } = readLayoutAnimationDiagnostics(renderer); + if (!animationDiagnostics || typeof animationDiagnostics !== "object") return; + const mode = animationDiagnostics.mode ?? diagnostics?.layoutAnimationMode; + const status = animationDiagnostics.status ?? diagnostics?.layoutAnimationStatus; + if (mode != null) assert.equal(typeof mode, "string", `${reason}: layout animation mode is string`); + if (status != null) assert.equal(typeof status, "string", `${reason}: layout animation status is string`); + assert.notEqual(status, "running", `${reason}: layout animation is not running`); + assert.notEqual(status, "scheduled", `${reason}: layout animation is not scheduled`); +} + +function assertLayoutAnimationDiagnosticsShape(renderer) { + const { diagnostics, animationDiagnostics } = readLayoutAnimationDiagnostics(renderer); + assert.ok(diagnostics, "layout diagnostics exist"); + if (!animationDiagnostics || typeof animationDiagnostics !== "object") return; + const mode = animationDiagnostics.mode ?? diagnostics.layoutAnimationMode; + const status = animationDiagnostics.status ?? diagnostics.layoutAnimationStatus; + if (mode != null) assert.equal(typeof mode, "string"); + if (status != null) assert.equal(typeof status, "string"); + if (animationDiagnostics.reducedMotion != null) { + assert.equal(typeof animationDiagnostics.reducedMotion, "boolean"); + } + if (animationDiagnostics.frameCount != null) { + assert.equal(Number.isFinite(Number(animationDiagnostics.frameCount)), true); + } +} + const { GraphRenderer } = await import("../ui/graph-renderer.js"); { @@ -437,4 +521,82 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js"); renderer.destroy(); } +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer({ reducedMotion: true }); + try { + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + assertLayoutAnimationNotRunning(renderer, "reduced motion disables animated layout"); + assertNoPendingRafOrTimers("reduced motion animated layout disabled"); + } finally { + renderer.destroy(); + restoreMatchMedia(); + } +} + +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer({ configOff: true }); + try { + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + assertLayoutAnimationNotRunning(renderer, "config off disables animated layout"); + assertNoPendingRafOrTimers("config off animated layout disabled"); + } finally { + renderer.destroy(); + restoreMatchMedia(); + } +} + +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer(); + try { + renderer.loadGraph(createStarSeedGraph(), { userPovAliases: ["Host"] }); + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + renderer.setEnabled(false); + assertLayoutAnimationNotRunning(renderer, "disable cancels animated layout"); + assertNoPendingRafOrTimers("disable cancels animated layout callbacks"); + } finally { + renderer.destroy(); + restoreMatchMedia(); + } +} + +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer(); + try { + renderer.loadGraph(createStarSeedGraph(), { userPovAliases: ["Host"] }); + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + renderer.destroy(); + assertLayoutAnimationNotRunning(renderer, "destroy cancels animated layout"); + assertNoPendingRafOrTimers("destroy cancels animated layout callbacks"); + } finally { + restoreMatchMedia(); + } +} + +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer(); + try { + renderer.loadGraph(createStarSeedGraph(), { userPovAliases: ["Host"] }); + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + const frameCount = flushRafsUntilIdle({ maxFrames: 80, ms: 16 }); + assert.ok(frameCount > 0, "animated layout actually used RAF frames"); + assert.ok(frameCount <= 80, "animated layout RAFs are bounded"); + assertLayoutAnimationNotRunning(renderer, "bounded animated layout settles"); + assertNoPendingRafOrTimers("bounded animated layout settles without RAF/timer leaks"); + } finally { + renderer.destroy(); + restoreMatchMedia(); + } +} + +{ + const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer(); + try { + renderer.loadGraph(createStarSeedGraph({ includeFragment: true }), { userPovAliases: ["Host"] }); + assertLayoutAnimationDiagnosticsShape(renderer); + } finally { + renderer.destroy(); + restoreMatchMedia(); + } +} + console.log("graph-renderer guardrail tests passed"); diff --git a/ui/graph-native-bridge.js b/ui/graph-native-bridge.js index 73a5de2..554e023 100644 --- a/ui/graph-native-bridge.js +++ b/ui/graph-native-bridge.js @@ -5,6 +5,9 @@ const DEFAULT_NATIVE_RUNTIME_OPTIONS = Object.freeze({ graphNativeLayoutWorkerTimeoutMs: 260, nativeEngineFailOpen: true, graphNativeForceDisable: false, + graphAnimatedLayout: true, + graphLayoutAnimation: true, + graphLayoutAnimationEnabled: true, }); function clampPositiveInt(value, fallback, { min = 1, max = 120000 } = {}) { @@ -57,6 +60,18 @@ export function normalizeGraphNativeRuntimeOptions(options = {}) { source.graphNativeForceDisable, DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeForceDisable, ), + graphAnimatedLayout: normalizeBoolean( + source.graphAnimatedLayout, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphAnimatedLayout, + ), + graphLayoutAnimation: normalizeBoolean( + source.graphLayoutAnimation, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphLayoutAnimation, + ), + graphLayoutAnimationEnabled: normalizeBoolean( + source.graphLayoutAnimationEnabled, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphLayoutAnimationEnabled, + ), }; } diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index e1bc41a..360a182 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -1,5 +1,5 @@ // ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局 -// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动 +// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 有预算的短力导向动画 import { getNodeColors, LIGHT_PANEL_THEMES, THEMES } from './themes.js'; import { @@ -38,7 +38,7 @@ const DEFAULT_LAYOUT_CONFIG = { gridColor: 'rgba(255,255,255,0.028)', /** 主画布左侧客观区占比(余下为右侧 POV 列) */ objectiveWidthRatio: 0.62, - /** 分区内类神经布局:力导向迭代次数(无持续动画,仅一次性稳定) */ + /** 分区内类神经布局:力导向迭代次数 */ neuralIterations: 120, neuralRepulsion: 2800, neuralSpringK: 0.048, @@ -46,6 +46,20 @@ const DEFAULT_LAYOUT_CONFIG = { neuralCenterGravity: 0.014, /** 节点最小间距(除半径外) */ neuralMinGap: 12, + /** 小/中图加载后短暂继续布局,模拟 GitNexus 式自然展开,但受预算硬限制 */ + animatedLayout: true, + layoutAnimation: true, + layoutAnimationEnabled: true, + layoutAnimationMaxNodes: 520, + layoutAnimationMaxEdges: 3600, + layoutAnimationDurationMs: 1400, + layoutAnimationMaxFrames: 120, + layoutAnimationIterationsPerFrame: 2, + layoutAnimationInitialIterationRatio: 0.38, + layoutAnimationMinInitialIterations: 8, + layoutAnimationRestartWindowMs: 5000, + layoutAnimationRestartMax: 2, + layoutAnimationCooldownMs: 9000, }; const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({ @@ -332,6 +346,16 @@ export class GraphRenderer { ); this._nativeLayoutBridge = null; this._layoutSolveRevision = 0; + this._layoutAnimId = null; + this._layoutAnimationState = null; + this._layoutAnimationCooldownUntil = 0; + this._layoutAnimationStarts = []; + this._lastLayoutAnimationDiagnostics = { + enabled: false, + status: 'idle', + reason: 'not-started', + frameCount: 0, + }; this._lastLayoutDiagnostics = null; this._lastLayoutReuseStats = { reused: 0, total: 0, ratio: 0 }; this._lastLayoutSeedModeCounts = { @@ -395,6 +419,7 @@ export class GraphRenderer { const prevSelectedId = this.selectedNode?.id || null; const solveRevision = this._nextLayoutSolveRevision(); const previousLayoutSeedByNodeId = this._captureLayoutSeedByNodeId(); + this._cancelLayoutAnimation('graph-load-replaced'); this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced'); this._lastGraph = graph; this._lastLayoutHints = layoutHints && typeof layoutHints === 'object' @@ -534,6 +559,10 @@ export class GraphRenderer { this.nodes.length, this.edges.length, ); + const animationPlan = this._resolveLayoutAnimationPlan(neuralPlan, { + shouldTryNativeLayout, + solveRevision, + }); let solvePath = neuralPlan.skip ? 'skipped' : 'js-main'; let solveMs = 0; @@ -555,8 +584,13 @@ export class GraphRenderer { ); } else { const solveStartedAt = performance.now(); - this._simulateNeuralWithinRegions(neuralPlan.iterations); + this._simulateNeuralWithinRegions( + animationPlan.shouldAnimate + ? animationPlan.initialIterations + : neuralPlan.iterations, + ); solveMs = Math.max(0, performance.now() - solveStartedAt); + if (animationPlan.shouldAnimate) solvePath = 'js-main-animated'; } } @@ -580,8 +614,20 @@ export class GraphRenderer { layoutReuseCount: Number(layoutReuse?.reused || 0), layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), layoutReuseRatio: Number(layoutReuse?.ratio || 0), + layoutAnimation: animationPlan.diagnostics, at: Date.now(), }); + if (animationPlan.shouldAnimate) { + this._startAnimatedLayout({ + ...animationPlan, + loadStartedAt, + prepareFinishedAt, + layoutFinishedAt, + solveStartedAt: performance.now(), + layoutReuse, + baseLayoutDiagnostics, + }); + } return; } @@ -732,6 +778,7 @@ export class GraphRenderer { this._setLastLayoutDiagnostics(this._lastLayoutDiagnostics); } this._cancelAnim(); + this._cancelLayoutAnimation('renderer-disabled'); this.dragNode = null; this.isDragging = false; this.isPanning = false; @@ -1227,6 +1274,128 @@ export class GraphRenderer { return this._layoutSolveRevision; } + _isLayoutAnimationConfigEnabled() { + return !( + this.config?.animatedLayout === false + || this.config?.layoutAnimation === false + || this.config?.layoutAnimationEnabled === false + || this.runtimeConfig?.graphAnimatedLayout === false + || this.runtimeConfig?.graphLayoutAnimation === false + || this.runtimeConfig?.graphLayoutAnimationEnabled === false + ); + } + + _consumeLayoutAnimationBudget(now = this._nowMs()) { + const windowMs = Math.max(0, Number(this.config.layoutAnimationRestartWindowMs) || 5000); + const maxStarts = Math.max(0, Math.trunc(Number(this.config.layoutAnimationRestartMax) || 2)); + const cooldownMs = Math.max(0, Number(this.config.layoutAnimationCooldownMs) || 9000); + if (now < Number(this._layoutAnimationCooldownUntil || 0)) { + return { + allowed: false, + reason: 'cooldown', + cooldownUntil: this._layoutAnimationCooldownUntil, + }; + } + this._layoutAnimationStarts = (this._layoutAnimationStarts || []) + .filter((startedAt) => now - startedAt < windowMs); + if (this._layoutAnimationStarts.length >= maxStarts) { + this._layoutAnimationCooldownUntil = now + cooldownMs; + return { + allowed: false, + reason: 'budget-exhausted', + cooldownUntil: this._layoutAnimationCooldownUntil, + }; + } + this._layoutAnimationStarts.push(now); + return { + allowed: true, + reason: 'allowed', + cooldownUntil: this._layoutAnimationCooldownUntil, + }; + } + + _resolveLayoutAnimationPlan(neuralPlan = {}, context = {}) { + const iterations = Math.max(0, Math.trunc(Number(neuralPlan?.iterations) || 0)); + const baseDiagnostics = { + enabled: false, + status: 'disabled', + reason: 'not-evaluated', + reducedMotion: this._isReducedMotion(), + frameCount: 0, + remainingIterations: 0, + cooldownUntil: Number(this._layoutAnimationCooldownUntil || 0), + }; + + const disabled = (reason, extra = {}) => ({ + shouldAnimate: false, + initialIterations: iterations, + remainingIterations: 0, + diagnostics: { + ...baseDiagnostics, + ...extra, + reason, + status: 'disabled', + }, + }); + + if (neuralPlan?.skip || iterations <= 0) return disabled('simulation-skipped'); + if (!this.enabled) return disabled('renderer-disabled'); + if (!this._isLayoutAnimationConfigEnabled()) return disabled('config-disabled'); + if (baseDiagnostics.reducedMotion) return disabled('reduced-motion'); + if (context?.shouldTryNativeLayout) return disabled('native-worker'); + + const nodeCount = this.nodes.length; + const edgeCount = this.edges.length; + const maxNodes = Math.max(0, Number(this.config.layoutAnimationMaxNodes) || 520); + const maxEdges = Math.max(0, Number(this.config.layoutAnimationMaxEdges) || 3600); + if (nodeCount > maxNodes || edgeCount > maxEdges) { + return disabled('graph-too-large', { nodeCount, edgeCount, maxNodes, maxEdges }); + } + + const ratio = Math.max(0.05, Math.min(0.95, Number(this.config.layoutAnimationInitialIterationRatio) || 0.38)); + const minInitial = Math.max(1, Math.trunc(Number(this.config.layoutAnimationMinInitialIterations) || 8)); + const initialIterations = Math.max( + 1, + Math.min(iterations, Math.max(minInitial, Math.round(iterations * ratio))), + ); + const remainingIterations = Math.max(0, iterations - initialIterations); + if (remainingIterations <= 0) { + return disabled('no-remaining-iterations', { initialIterations }); + } + + const budget = this._consumeLayoutAnimationBudget(this._nowMs()); + if (!budget.allowed) { + return disabled(budget.reason || 'layout-budget-denied', { + cooldownUntil: budget.cooldownUntil, + }); + } + + const maxMs = Math.max(160, Math.min(6000, Number(this.config.layoutAnimationDurationMs) || 1400)); + const maxFrames = Math.max(1, Math.min(360, Math.trunc(Number(this.config.layoutAnimationMaxFrames) || 120))); + const iterationsPerFrame = Math.max(1, Math.min(8, Math.trunc(Number(this.config.layoutAnimationIterationsPerFrame) || 2))); + return { + shouldAnimate: true, + solveRevision: context?.solveRevision, + initialIterations, + remainingIterations, + maxMs, + maxFrames, + iterationsPerFrame, + diagnostics: { + ...baseDiagnostics, + enabled: true, + status: 'scheduled', + reason: 'scheduled', + initialIterations, + remainingIterations, + maxMs, + maxFrames, + iterationsPerFrame, + cooldownUntil: budget.cooldownUntil, + }, + }; + } + _ensureNativeLayoutBridge() { if (this._nativeLayoutBridge) { this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig); @@ -1420,11 +1589,160 @@ export class GraphRenderer { }; } + _startAnimatedLayout(plan = {}) { + if (!plan?.shouldAnimate || !this.enabled) return false; + this._cancelLayoutAnimation('replaced'); + + const startedAt = this._nowMs(); + const state = { + solveRevision: Number(plan.solveRevision || this._layoutSolveRevision), + startedAt, + remainingIterations: Math.max(0, Math.trunc(Number(plan.remainingIterations) || 0)), + maxMs: Math.max(1, Number(plan.maxMs) || 1400), + maxFrames: Math.max(1, Math.trunc(Number(plan.maxFrames) || 120)), + iterationsPerFrame: Math.max(1, Math.trunc(Number(plan.iterationsPerFrame) || 2)), + frameCount: 0, + solveMs: 0, + loadStartedAt: Number(plan.loadStartedAt) || startedAt, + prepareFinishedAt: Number(plan.prepareFinishedAt) || startedAt, + layoutFinishedAt: Number(plan.layoutFinishedAt) || startedAt, + layoutReuse: plan.layoutReuse && typeof plan.layoutReuse === 'object' + ? plan.layoutReuse + : this._lastLayoutReuseStats, + baseLayoutDiagnostics: plan.baseLayoutDiagnostics && typeof plan.baseLayoutDiagnostics === 'object' + ? { ...plan.baseLayoutDiagnostics } + : {}, + }; + if (state.remainingIterations <= 0) return false; + + this._layoutAnimationState = state; + this._updateLayoutAnimationDiagnostics({ status: 'scheduled', reason: 'scheduled' }); + this._scheduleLayoutAnimationFrame(); + return true; + } + + _scheduleLayoutAnimationFrame() { + if (!this.enabled || this._layoutAnimId || !this._layoutAnimationState) return; + this._layoutAnimId = requestAnimationFrame((timestamp) => { + this._layoutAnimId = null; + this._tickLayoutAnimation(timestamp); + }); + } + + _tickLayoutAnimation(timestamp = this._nowMs()) { + const state = this._layoutAnimationState; + if (!state) return; + if (!this.enabled || state.solveRevision !== this._layoutSolveRevision) { + this._cancelLayoutAnimation('stale-or-disabled'); + return; + } + + const elapsedMs = Math.max(0, Number(timestamp || this._nowMs()) - state.startedAt); + if (elapsedMs >= state.maxMs || state.frameCount >= state.maxFrames) { + this._finishLayoutAnimation('budget-complete'); + return; + } + + const iterations = Math.min(state.iterationsPerFrame, state.remainingIterations); + const frameStartedAt = performance.now(); + this._simulateNeuralWithinRegions(iterations, { minIterations: 1 }); + state.solveMs += Math.max(0, performance.now() - frameStartedAt); + state.remainingIterations = Math.max(0, state.remainingIterations - iterations); + state.frameCount += 1; + this._updateLayoutAnimationDiagnostics({ status: 'running', reason: 'running' }); + this._render(); + + if (state.remainingIterations <= 0) { + this._finishLayoutAnimation('settled'); + return; + } + this._scheduleLayoutAnimationFrame(); + } + + _finishLayoutAnimation(reason = 'settled') { + const state = this._layoutAnimationState; + if (!state) return; + this._layoutAnimationState = null; + this._updateLayoutAnimationDiagnostics({ + status: 'idle', + reason, + frameCount: state.frameCount, + remainingIterations: state.remainingIterations, + solveMs: state.solveMs, + }); + this._setLastLayoutDiagnostics({ + mode: 'js-main-animated', + ...state.baseLayoutDiagnostics, + prepareMs: Math.max(0, state.prepareFinishedAt - state.loadStartedAt), + layoutSeedMs: Math.max(0, state.layoutFinishedAt - state.prepareFinishedAt), + solveMs: Math.max(0, Number(this._lastLayoutDiagnostics?.solveMs || 0) + state.solveMs), + totalMs: Math.max(0, performance.now() - state.loadStartedAt), + layoutReuseCount: Number(state.layoutReuse?.reused || 0), + layoutReuseTotal: Number(state.layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(state.layoutReuse?.ratio || 0), + layoutAnimation: this._lastLayoutAnimationDiagnostics, + at: Date.now(), + }); + if (this.enabled) this._render(); + } + + _updateLayoutAnimationDiagnostics(patch = {}) { + const state = this._layoutAnimationState; + this._lastLayoutAnimationDiagnostics = { + ...this._lastLayoutAnimationDiagnostics, + enabled: Boolean(state || this._lastLayoutAnimationDiagnostics?.enabled), + status: state ? 'running' : 'idle', + reason: '', + reducedMotion: this._isReducedMotion(), + frameCount: Number(state?.frameCount || 0), + remainingIterations: Number(state?.remainingIterations || 0), + cooldownUntil: Number(this._layoutAnimationCooldownUntil || 0), + ...patch, + }; + if (this._lastLayoutDiagnostics) { + this._lastLayoutDiagnostics = { + ...this._lastLayoutDiagnostics, + layoutAnimation: { ...this._lastLayoutAnimationDiagnostics }, + }; + recordGraphLayoutDebugSnapshot({ + ...this._lastLayoutDiagnostics, + enabled: this.enabled !== false, + }); + } + } + + _cancelLayoutAnimation(reason = 'cancelled') { + if (this._layoutAnimId) { + cancelAnimationFrame(this._layoutAnimId); + this._layoutAnimId = null; + } + if (this._layoutAnimationState) { + const state = this._layoutAnimationState; + this._layoutAnimationState = null; + this._updateLayoutAnimationDiagnostics({ + enabled: true, + status: 'cancelled', + reason, + frameCount: state.frameCount, + remainingIterations: state.remainingIterations, + }); + } + } + /** - * 分区内一次性力导向:斥力 + 同区边弹簧 + 弱向心,稳定后停止(无帧循环) + * 分区内力导向:斥力 + 同区边弹簧 + 弱向心。可一次性跑完,也可被短 RAF 动画分帧调用。 */ - _simulateNeuralWithinRegions(iterations) { - const iters = Math.max(8, Math.min(220, iterations || 80)); + _simulateNeuralWithinRegions(iterations, options = {}) { + const minIterations = Number.isFinite(Number(options.minIterations)) + ? Math.max(1, Math.trunc(Number(options.minIterations))) + : 8; + const iters = Math.max(minIterations, Math.min(220, Math.trunc(iterations || 80))); + for (let it = 0; it < iters; it++) { + this._simulateNeuralIteration(); + } + } + + _simulateNeuralIteration() { const repulsion = this.config.neuralRepulsion ?? 2800; const springK = this.config.neuralSpringK ?? 0.048; const damping = this.config.neuralDamping ?? 0.88; @@ -1433,79 +1751,77 @@ export class GraphRenderer { const springIdeal = this._idealSpringLengthsByRegion(); const nodes = this.nodes; - for (let it = 0; it < iters; it++) { - for (const n of nodes) { - n._fx = 0; - n._fy = 0; - } + for (const n of nodes) { + n._fx = 0; + n._fy = 0; + } - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { - const a = nodes[i]; - const b = nodes[j]; - if (a.regionKey !== b.regionKey) continue; - let dx = b.x - a.x; - let dy = b.y - a.y; - let distSq = dx * dx + dy * dy; - if (distSq < 0.25) distSq = 0.25; - const dist = Math.sqrt(distSq); - const minSep = - this._nodeRadius(a) + this._nodeRadius(b) + extraGap; - let f = repulsion / distSq; - if (dist < minSep) { - f += (minSep - dist) * 0.22; - } - const fx = (dx / dist) * f; - const fy = (dy / dist) * f; - a._fx -= fx; - a._fy -= fy; - b._fx += fx; - b._fy += fy; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i]; + const b = nodes[j]; + if (a.regionKey !== b.regionKey) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + let distSq = dx * dx + dy * dy; + if (distSq < 0.25) distSq = 0.25; + const dist = Math.sqrt(distSq); + const minSep = + this._nodeRadius(a) + this._nodeRadius(b) + extraGap; + let f = repulsion / distSq; + if (dist < minSep) { + f += (minSep - dist) * 0.22; } - } - - for (const edge of this.edges) { - const { from, to, strength } = edge; - if (from.regionKey !== to.regionKey) continue; - const ideal = - springIdeal.get(from.regionKey) ?? 68; - let dx = to.x - from.x; - let dy = to.y - from.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 0.001; - const displacement = dist - ideal * (0.82 + 0.18 * strength); - const f = springK * displacement * (0.45 + 0.55 * strength); const fx = (dx / dist) * f; const fy = (dy / dist) * f; - from._fx += fx; - from._fy += fy; - to._fx -= fx; - to._fy -= fy; + a._fx -= fx; + a._fy -= fy; + b._fx += fx; + b._fy += fy; } + } - for (const node of nodes) { - const rect = node.regionRect; - if (!rect) continue; - const ccx = rect.x + rect.w / 2; - const ccy = rect.y + rect.h / 2; - node._fx += (ccx - node.x) * cg; - node._fy += (ccy - node.y) * cg; - } + for (const edge of this.edges) { + const { from, to, strength } = edge; + if (from.regionKey !== to.regionKey) continue; + const ideal = + springIdeal.get(from.regionKey) ?? 68; + let dx = to.x - from.x; + let dy = to.y - from.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.001; + const displacement = dist - ideal * (0.82 + 0.18 * strength); + const f = springK * displacement * (0.45 + 0.55 * strength); + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + from._fx += fx; + from._fy += fy; + to._fx -= fx; + to._fy -= fy; + } - for (const node of nodes) { - node.vx = (node.vx + node._fx) * damping; - node.vy = (node.vy + node._fy) * damping; - const sp = Math.hypot(node.vx, node.vy); - const cap = 3.8; - if (sp > cap) { - node.vx = (node.vx / sp) * cap; - node.vy = (node.vy / sp) * cap; - } - node.x += node.vx; - node.y += node.vy; - delete node._fx; - delete node._fy; - this._clampNodeToRegion(node); + for (const node of nodes) { + const rect = node.regionRect; + if (!rect) continue; + const ccx = rect.x + rect.w / 2; + const ccy = rect.y + rect.h / 2; + node._fx += (ccx - node.x) * cg; + node._fy += (ccy - node.y) * cg; + } + + for (const node of nodes) { + node.vx = (node.vx + node._fx) * damping; + node.vy = (node.vy + node._fy) * damping; + const sp = Math.hypot(node.vx, node.vy); + const cap = 3.8; + if (sp > cap) { + node.vx = (node.vx / sp) * cap; + node.vy = (node.vy / sp) * cap; } + node.x += node.vx; + node.y += node.vy; + delete node._fx; + delete node._fy; + this._clampNodeToRegion(node); } } @@ -2043,6 +2359,8 @@ export class GraphRenderer { stopAnimation() { this._cancelAnim(); + this._cancelLayoutAnimation('stop-animation'); + this._cancelHighlightAnimationFrame(); } _bindEvents() { @@ -2309,6 +2627,7 @@ export class GraphRenderer { destroy() { this._nextLayoutSolveRevision(); this._cancelAnim(); + this._cancelLayoutAnimation('destroy'); this._clearTransientHighlights({ cancelAnimation: true }); this._nativeLayoutBridge?.dispose?.(); this._nativeLayoutBridge = null;