mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
feat(graph-ui): animate bounded graph layout
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user