mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
perf: optimize persist delta gating and diagnostics
This commit is contained in:
303
ui/graph-layout-solver.js
Normal file
303
ui/graph-layout-solver.js
Normal file
@@ -0,0 +1,303 @@
|
||||
const DEFAULT_LAYOUT_CONFIG = Object.freeze({
|
||||
iterations: 80,
|
||||
repulsion: 2800,
|
||||
springK: 0.048,
|
||||
damping: 0.88,
|
||||
centerGravity: 0.014,
|
||||
minGap: 12,
|
||||
speedCap: 3.8,
|
||||
});
|
||||
|
||||
function clampFinite(value, fallback = 0, min = -Infinity, max = Infinity) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.max(min, Math.min(max, numeric));
|
||||
}
|
||||
|
||||
function normalizeConfig(raw = {}) {
|
||||
return {
|
||||
iterations: Math.max(
|
||||
8,
|
||||
Math.min(220, Math.floor(clampFinite(raw.iterations, DEFAULT_LAYOUT_CONFIG.iterations, 1, 220))),
|
||||
),
|
||||
repulsion: clampFinite(raw.repulsion, DEFAULT_LAYOUT_CONFIG.repulsion, 100, 120000),
|
||||
springK: clampFinite(raw.springK, DEFAULT_LAYOUT_CONFIG.springK, 0.001, 1.0),
|
||||
damping: clampFinite(raw.damping, DEFAULT_LAYOUT_CONFIG.damping, 0.1, 0.999),
|
||||
centerGravity: clampFinite(
|
||||
raw.centerGravity,
|
||||
DEFAULT_LAYOUT_CONFIG.centerGravity,
|
||||
0.0001,
|
||||
1,
|
||||
),
|
||||
minGap: clampFinite(raw.minGap, DEFAULT_LAYOUT_CONFIG.minGap, 0, 120),
|
||||
speedCap: clampFinite(raw.speedCap, DEFAULT_LAYOUT_CONFIG.speedCap, 0.5, 20),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePanel(raw = null) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { x: 0, y: 0, w: 0, h: 0 };
|
||||
}
|
||||
return {
|
||||
x: clampFinite(raw.x, 0),
|
||||
y: clampFinite(raw.y, 0),
|
||||
w: Math.max(0, clampFinite(raw.w, 0)),
|
||||
h: Math.max(0, clampFinite(raw.h, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNode(raw = {}) {
|
||||
const rect = normalizePanel(raw.regionRect);
|
||||
return {
|
||||
x: clampFinite(raw.x, 0),
|
||||
y: clampFinite(raw.y, 0),
|
||||
vx: clampFinite(raw.vx, 0),
|
||||
vy: clampFinite(raw.vy, 0),
|
||||
pinned: raw.pinned === true,
|
||||
radius: Math.max(1, clampFinite(raw.radius, 8, 1, 96)),
|
||||
regionKey: String(raw.regionKey || "objective"),
|
||||
regionRect: rect,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEdge(raw = {}, nodeCount = 0) {
|
||||
const from = Math.floor(clampFinite(raw.from, -1));
|
||||
const to = Math.floor(clampFinite(raw.to, -1));
|
||||
if (from < 0 || to < 0 || from >= nodeCount || to >= nodeCount || from === to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
strength: clampFinite(raw.strength, 0.5, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function clampNodeToRegion(state, index) {
|
||||
const rect = state.rects[index];
|
||||
const radius = state.radius[index] + 6;
|
||||
state.x[index] = Math.max(rect.x + radius, Math.min(rect.x + rect.w - radius, state.x[index]));
|
||||
state.y[index] = Math.max(rect.y + radius, Math.min(rect.y + rect.h - radius, state.y[index]));
|
||||
}
|
||||
|
||||
function computeSpringIdealByRegion(nodes = []) {
|
||||
const countByRegion = new Map();
|
||||
for (const node of nodes) {
|
||||
countByRegion.set(node.regionKey, (countByRegion.get(node.regionKey) || 0) + 1);
|
||||
}
|
||||
const idealByRegion = new Map();
|
||||
for (const node of nodes) {
|
||||
if (idealByRegion.has(node.regionKey)) continue;
|
||||
const rect = node.regionRect;
|
||||
const count = Math.max(1, countByRegion.get(node.regionKey) || 1);
|
||||
const area = Math.max(1, (rect?.w || 1) * (rect?.h || 1));
|
||||
const ideal = Math.max(36, Math.min(92, 0.78 * Math.sqrt(area / count)));
|
||||
idealByRegion.set(node.regionKey, ideal);
|
||||
}
|
||||
return idealByRegion;
|
||||
}
|
||||
|
||||
function buildRegionBuckets(nodes = []) {
|
||||
const bucketByRegion = new Map();
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const key = nodes[index].regionKey;
|
||||
if (!bucketByRegion.has(key)) {
|
||||
bucketByRegion.set(key, []);
|
||||
}
|
||||
bucketByRegion.get(key).push(index);
|
||||
}
|
||||
return bucketByRegion;
|
||||
}
|
||||
|
||||
function buildInRegionEdges(nodes = [], edges = []) {
|
||||
const result = [];
|
||||
for (const edge of edges) {
|
||||
const fromRegion = nodes[edge.from]?.regionKey;
|
||||
const toRegion = nodes[edge.to]?.regionKey;
|
||||
if (!fromRegion || fromRegion !== toRegion) continue;
|
||||
result.push({
|
||||
from: edge.from,
|
||||
to: edge.to,
|
||||
strength: edge.strength,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRegionCenters(nodes = []) {
|
||||
const centerX = new Float32Array(nodes.length);
|
||||
const centerY = new Float32Array(nodes.length);
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const rect = nodes[index]?.regionRect || { x: 0, y: 0, w: 0, h: 0 };
|
||||
centerX[index] = rect.x + rect.w / 2;
|
||||
centerY[index] = rect.y + rect.h / 2;
|
||||
}
|
||||
return { centerX, centerY };
|
||||
}
|
||||
|
||||
function buildSimulationState(nodes = []) {
|
||||
const length = nodes.length;
|
||||
const x = new Float32Array(length);
|
||||
const y = new Float32Array(length);
|
||||
const vx = new Float32Array(length);
|
||||
const vy = new Float32Array(length);
|
||||
const fx = new Float32Array(length);
|
||||
const fy = new Float32Array(length);
|
||||
const radius = new Float32Array(length);
|
||||
const pinned = new Uint8Array(length);
|
||||
const rects = new Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const node = nodes[i];
|
||||
x[i] = node.x;
|
||||
y[i] = node.y;
|
||||
vx[i] = node.vx;
|
||||
vy[i] = node.vy;
|
||||
radius[i] = node.radius;
|
||||
pinned[i] = node.pinned ? 1 : 0;
|
||||
rects[i] = node.regionRect;
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
fx,
|
||||
fy,
|
||||
radius,
|
||||
pinned,
|
||||
rects,
|
||||
};
|
||||
}
|
||||
|
||||
export function solveLayoutWithJs(payload = {}) {
|
||||
const startedAt = performance.now();
|
||||
const nodes = Array.isArray(payload.nodes) ? payload.nodes.map(normalizeNode) : [];
|
||||
const config = normalizeConfig(payload.config || {});
|
||||
const edgesRaw = Array.isArray(payload.edges) ? payload.edges : [];
|
||||
const edges = edgesRaw
|
||||
.map((edge) => normalizeEdge(edge, nodes.length))
|
||||
.filter(Boolean);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
positions: new Float32Array(0),
|
||||
diagnostics: {
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
elapsedMs: 0,
|
||||
solver: "js-worker",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const springIdealByRegion = computeSpringIdealByRegion(nodes);
|
||||
const regionBuckets = buildRegionBuckets(nodes);
|
||||
const state = buildSimulationState(nodes);
|
||||
const inRegionEdges = buildInRegionEdges(nodes, edges);
|
||||
const { centerX, centerY } = buildRegionCenters(nodes);
|
||||
let actualIterations = 0;
|
||||
let stableRounds = 0;
|
||||
|
||||
for (let iter = 0; iter < config.iterations; iter++) {
|
||||
actualIterations += 1;
|
||||
state.fx.fill(0);
|
||||
state.fy.fill(0);
|
||||
|
||||
for (const indexes of regionBuckets.values()) {
|
||||
for (let i = 0; i < indexes.length; i++) {
|
||||
const a = indexes[i];
|
||||
for (let j = i + 1; j < indexes.length; j++) {
|
||||
const b = indexes[j];
|
||||
const dx = state.x[b] - state.x[a];
|
||||
const dy = state.y[b] - state.y[a];
|
||||
let distSq = dx * dx + dy * dy;
|
||||
if (distSq < 0.25) distSq = 0.25;
|
||||
const dist = Math.sqrt(distSq);
|
||||
const minSep = state.radius[a] + state.radius[b] + config.minGap;
|
||||
let force = config.repulsion / distSq;
|
||||
if (dist < minSep) {
|
||||
force += (minSep - dist) * 0.22;
|
||||
}
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
state.fx[a] -= fx;
|
||||
state.fy[a] -= fy;
|
||||
state.fx[b] += fx;
|
||||
state.fy[b] += fy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of inRegionEdges) {
|
||||
const from = edge.from;
|
||||
const to = edge.to;
|
||||
const ideal = springIdealByRegion.get(nodes[from].regionKey) ?? 68;
|
||||
const dx = state.x[to] - state.x[from];
|
||||
const dy = state.y[to] - state.y[from];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
|
||||
const displacement = dist - ideal * (0.82 + 0.18 * edge.strength);
|
||||
const force = config.springK * displacement * (0.45 + 0.55 * edge.strength);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
state.fx[from] += fx;
|
||||
state.fy[from] += fy;
|
||||
state.fx[to] -= fx;
|
||||
state.fy[to] -= fy;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
state.fx[i] += (centerX[i] - state.x[i]) * config.centerGravity;
|
||||
state.fy[i] += (centerY[i] - state.y[i]) * config.centerGravity;
|
||||
}
|
||||
|
||||
let maxSpeed = 0;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (state.pinned[i]) {
|
||||
continue;
|
||||
}
|
||||
state.vx[i] = (state.vx[i] + state.fx[i]) * config.damping;
|
||||
state.vy[i] = (state.vy[i] + state.fy[i]) * config.damping;
|
||||
const speed = Math.hypot(state.vx[i], state.vy[i]);
|
||||
if (speed > maxSpeed) {
|
||||
maxSpeed = speed;
|
||||
}
|
||||
if (speed > config.speedCap) {
|
||||
state.vx[i] = (state.vx[i] / speed) * config.speedCap;
|
||||
state.vy[i] = (state.vy[i] / speed) * config.speedCap;
|
||||
}
|
||||
state.x[i] += state.vx[i];
|
||||
state.y[i] += state.vy[i];
|
||||
clampNodeToRegion(state, i);
|
||||
}
|
||||
|
||||
if (maxSpeed < 0.015) {
|
||||
stableRounds += 1;
|
||||
if (stableRounds >= 6) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
stableRounds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const positions = new Float32Array(nodes.length * 2);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
positions[i * 2] = state.x[i];
|
||||
positions[i * 2 + 1] = state.y[i];
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
positions,
|
||||
diagnostics: {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
elapsedMs: Math.max(0, performance.now() - startedAt),
|
||||
solver: "js-worker",
|
||||
iterations: actualIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user