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,
|
||||
},
|
||||
};
|
||||
}
|
||||
141
ui/graph-layout-worker.js
Normal file
141
ui/graph-layout-worker.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { solveLayoutWithJs } from "./graph-layout-solver.js";
|
||||
|
||||
let nativeSolver = null;
|
||||
let nativeLoadAttempted = false;
|
||||
let nativeLoadError = "";
|
||||
let readNativeModuleStatus = null;
|
||||
const canceledJobIds = new Set();
|
||||
const activeJobIds = new Set();
|
||||
|
||||
async function ensureNativeSolver() {
|
||||
if (nativeLoadAttempted) return nativeSolver;
|
||||
nativeLoadAttempted = true;
|
||||
|
||||
try {
|
||||
const nativeModule = await import("../vendor/wasm/stbme_core.js");
|
||||
const solveLayout =
|
||||
nativeModule?.solveLayout || nativeModule?.default?.solveLayout || null;
|
||||
nativeSolver = typeof solveLayout === "function" ? solveLayout : null;
|
||||
readNativeModuleStatus =
|
||||
typeof nativeModule?.getNativeModuleStatus === "function"
|
||||
? nativeModule.getNativeModuleStatus
|
||||
: null;
|
||||
} catch (error) {
|
||||
nativeLoadError = error?.message || String(error);
|
||||
nativeSolver = null;
|
||||
}
|
||||
|
||||
return nativeSolver;
|
||||
}
|
||||
|
||||
async function solveLayout(payload = {}) {
|
||||
const nativeRequested = payload?.nativeRequested === true;
|
||||
if (nativeRequested) {
|
||||
const solver = await ensureNativeSolver();
|
||||
if (solver) {
|
||||
try {
|
||||
const nativeResult = await solver(payload);
|
||||
const nativeStatus =
|
||||
typeof readNativeModuleStatus === "function"
|
||||
? readNativeModuleStatus()
|
||||
: null;
|
||||
if (
|
||||
nativeResult &&
|
||||
nativeResult.ok === true &&
|
||||
nativeResult.positions instanceof Float32Array
|
||||
) {
|
||||
return {
|
||||
...nativeResult,
|
||||
usedNative: true,
|
||||
diagnostics: {
|
||||
...(nativeResult.diagnostics || {}),
|
||||
solver: "rust-wasm",
|
||||
moduleSource: String(nativeStatus?.source || ""),
|
||||
nativeLoadError: String(nativeStatus?.error || nativeLoadError || ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "native-solver-failed",
|
||||
error: error?.message || String(error),
|
||||
nativeLoadError,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsResult = solveLayoutWithJs(payload);
|
||||
return {
|
||||
...jsResult,
|
||||
usedNative: false,
|
||||
nativeLoadError,
|
||||
};
|
||||
}
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
const message = event?.data || {};
|
||||
if (message.type === "cancel-layout") {
|
||||
const canceledJobId = Number(message.jobId);
|
||||
if (Number.isFinite(canceledJobId)) {
|
||||
canceledJobIds.add(canceledJobId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type !== "solve-layout") return;
|
||||
|
||||
const jobId = Number(message.jobId);
|
||||
if (!Number.isFinite(jobId)) return;
|
||||
if (canceledJobIds.has(jobId)) {
|
||||
canceledJobIds.delete(jobId);
|
||||
return;
|
||||
}
|
||||
activeJobIds.add(jobId);
|
||||
const fallbackErrorResult = (errorMessage = "unknown-worker-error") => ({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "worker-exception",
|
||||
error: String(errorMessage || "unknown-worker-error"),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await solveLayout(message.payload || {});
|
||||
if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) {
|
||||
activeJobIds.delete(jobId);
|
||||
canceledJobIds.delete(jobId);
|
||||
return;
|
||||
}
|
||||
activeJobIds.delete(jobId);
|
||||
if (result?.positions instanceof Float32Array) {
|
||||
self.postMessage(
|
||||
{
|
||||
type: "layout-result",
|
||||
jobId,
|
||||
result,
|
||||
},
|
||||
[result.positions.buffer],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: "layout-result",
|
||||
jobId,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) {
|
||||
activeJobIds.delete(jobId);
|
||||
canceledJobIds.delete(jobId);
|
||||
return;
|
||||
}
|
||||
activeJobIds.delete(jobId);
|
||||
self.postMessage({
|
||||
type: "layout-result",
|
||||
jobId,
|
||||
result: fallbackErrorResult(error?.message || String(error)),
|
||||
});
|
||||
}
|
||||
});
|
||||
246
ui/graph-native-bridge.js
Normal file
246
ui/graph-native-bridge.js
Normal file
@@ -0,0 +1,246 @@
|
||||
const DEFAULT_NATIVE_RUNTIME_OPTIONS = Object.freeze({
|
||||
graphUseNativeLayout: false,
|
||||
graphNativeLayoutThresholdNodes: 280,
|
||||
graphNativeLayoutThresholdEdges: 1600,
|
||||
graphNativeLayoutWorkerTimeoutMs: 260,
|
||||
nativeEngineFailOpen: true,
|
||||
graphNativeForceDisable: false,
|
||||
});
|
||||
|
||||
function clampPositiveInt(value, fallback, { min = 1, max = 120000 } = {}) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.floor(numeric)));
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "number") return Number.isFinite(value) && value !== 0;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function normalizeGraphNativeRuntimeOptions(options = {}) {
|
||||
const source =
|
||||
options && typeof options === "object" && !Array.isArray(options)
|
||||
? options
|
||||
: {};
|
||||
return {
|
||||
graphUseNativeLayout: normalizeBoolean(
|
||||
source.graphUseNativeLayout,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphUseNativeLayout,
|
||||
),
|
||||
graphNativeLayoutThresholdNodes: clampPositiveInt(
|
||||
source.graphNativeLayoutThresholdNodes,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdNodes,
|
||||
{ min: 1, max: 20000 },
|
||||
),
|
||||
graphNativeLayoutThresholdEdges: clampPositiveInt(
|
||||
source.graphNativeLayoutThresholdEdges,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdEdges,
|
||||
{ min: 1, max: 50000 },
|
||||
),
|
||||
graphNativeLayoutWorkerTimeoutMs: clampPositiveInt(
|
||||
source.graphNativeLayoutWorkerTimeoutMs,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutWorkerTimeoutMs,
|
||||
{ min: 40, max: 15000 },
|
||||
),
|
||||
nativeEngineFailOpen: normalizeBoolean(
|
||||
source.nativeEngineFailOpen,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.nativeEngineFailOpen,
|
||||
),
|
||||
graphNativeForceDisable: normalizeBoolean(
|
||||
source.graphNativeForceDisable,
|
||||
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeForceDisable,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class GraphNativeLayoutBridge {
|
||||
constructor(runtimeOptions = {}) {
|
||||
this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions);
|
||||
this._worker = null;
|
||||
this._workerBootError = "";
|
||||
this._nextJobId = 1;
|
||||
this._pendingJobs = new Map();
|
||||
this._isDisposed = false;
|
||||
}
|
||||
|
||||
updateRuntimeOptions(runtimeOptions = {}) {
|
||||
this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions);
|
||||
}
|
||||
|
||||
shouldRunForGraph(nodeCount = 0, edgeCount = 0) {
|
||||
if (this.runtimeOptions.graphNativeForceDisable) return false;
|
||||
if (!this.runtimeOptions.graphUseNativeLayout) return false;
|
||||
const normalizedNodes = Math.max(0, Number(nodeCount) || 0);
|
||||
const normalizedEdges = Math.max(0, Number(edgeCount) || 0);
|
||||
return (
|
||||
normalizedNodes >= this.runtimeOptions.graphNativeLayoutThresholdNodes ||
|
||||
normalizedEdges >= this.runtimeOptions.graphNativeLayoutThresholdEdges
|
||||
);
|
||||
}
|
||||
|
||||
async solveLayout(payload = {}, { timeoutMs = null } = {}) {
|
||||
if (this._isDisposed) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "bridge-disposed",
|
||||
};
|
||||
}
|
||||
|
||||
const worker = this._ensureWorker();
|
||||
if (!worker) {
|
||||
const result = {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "worker-unavailable",
|
||||
error: this._workerBootError || "Graph worker unavailable",
|
||||
};
|
||||
if (this.runtimeOptions.nativeEngineFailOpen) {
|
||||
return result;
|
||||
}
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const normalizedTimeoutMs = clampPositiveInt(
|
||||
timeoutMs,
|
||||
this.runtimeOptions.graphNativeLayoutWorkerTimeoutMs,
|
||||
{ min: 40, max: 15000 },
|
||||
);
|
||||
|
||||
const jobId = this._nextJobId++;
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this._worker) {
|
||||
this._worker.postMessage({
|
||||
type: "cancel-layout",
|
||||
jobId,
|
||||
reason: "native-layout-timeout",
|
||||
});
|
||||
}
|
||||
this._pendingJobs.delete(jobId);
|
||||
resolve({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "native-layout-timeout",
|
||||
timeoutMs: normalizedTimeoutMs,
|
||||
});
|
||||
}, normalizedTimeoutMs);
|
||||
|
||||
this._pendingJobs.set(jobId, {
|
||||
resolve,
|
||||
timer,
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
type: "solve-layout",
|
||||
jobId,
|
||||
payload: {
|
||||
...payload,
|
||||
nativeRequested: true,
|
||||
timeoutMs: normalizedTimeoutMs,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancelPending(reason = "native-layout-canceled") {
|
||||
if (this._pendingJobs.size <= 0) return;
|
||||
const cancelReason = String(reason || "native-layout-canceled").trim() ||
|
||||
"native-layout-canceled";
|
||||
for (const [jobId, pending] of this._pendingJobs.entries()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: cancelReason,
|
||||
});
|
||||
if (this._worker) {
|
||||
this._worker.postMessage({
|
||||
type: "cancel-layout",
|
||||
jobId,
|
||||
reason: cancelReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
this._pendingJobs.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
this.cancelPending("bridge-disposed");
|
||||
if (this._worker) {
|
||||
this._worker.terminate();
|
||||
this._worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
_ensureWorker() {
|
||||
if (this._worker) return this._worker;
|
||||
if (this._isDisposed) return null;
|
||||
if (typeof Worker !== "function") {
|
||||
this._workerBootError = "Worker API unavailable";
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const worker = new Worker(new URL("./graph-layout-worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
worker.addEventListener("message", (event) => {
|
||||
this._handleWorkerMessage(event?.data || {});
|
||||
});
|
||||
worker.addEventListener("error", (event) => {
|
||||
const message =
|
||||
String(event?.message || "").trim() || "Graph layout worker error";
|
||||
this._workerBootError = message;
|
||||
this._resolveAllPending({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "native-worker-error",
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
this._worker = worker;
|
||||
return worker;
|
||||
} catch (error) {
|
||||
this._workerBootError = error?.message || String(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_handleWorkerMessage(data = {}) {
|
||||
if (!data || data.type !== "layout-result") return;
|
||||
const jobId = Number(data.jobId);
|
||||
if (!Number.isFinite(jobId)) return;
|
||||
const pending = this._pendingJobs.get(jobId);
|
||||
if (!pending) return;
|
||||
this._pendingJobs.delete(jobId);
|
||||
clearTimeout(pending.timer);
|
||||
|
||||
const result = data.result || {};
|
||||
if (result?.positions && !(result.positions instanceof Float32Array)) {
|
||||
try {
|
||||
result.positions = Float32Array.from(result.positions);
|
||||
} catch {
|
||||
result.positions = null;
|
||||
}
|
||||
}
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
_resolveAllPending(result = {}) {
|
||||
for (const pending of this._pendingJobs.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(result);
|
||||
}
|
||||
this._pendingJobs.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
aliasSetMatchesValue,
|
||||
buildUserPovAliasNormalizedSet,
|
||||
} from '../runtime/user-alias-utils.js';
|
||||
import {
|
||||
GraphNativeLayoutBridge,
|
||||
normalizeGraphNativeRuntimeOptions,
|
||||
} from './graph-native-bridge.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} GraphNode
|
||||
@@ -56,6 +60,39 @@ const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({
|
||||
});
|
||||
|
||||
const MIN_USABLE_CANVAS_DIMENSION = 48;
|
||||
const RUNTIME_DEBUG_STATE_KEY = '__stBmeRuntimeDebugState';
|
||||
|
||||
function cloneGraphLayoutDebugValue(value, fallback = null) {
|
||||
if (value == null) return fallback;
|
||||
if (typeof globalThis.structuredClone === 'function') {
|
||||
try {
|
||||
return globalThis.structuredClone(value);
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function recordGraphLayoutDebugSnapshot(snapshot = null) {
|
||||
if (!globalThis || typeof globalThis !== 'object') return;
|
||||
if (!globalThis[RUNTIME_DEBUG_STATE_KEY] || typeof globalThis[RUNTIME_DEBUG_STATE_KEY] !== 'object') {
|
||||
globalThis[RUNTIME_DEBUG_STATE_KEY] = {
|
||||
updatedAt: '',
|
||||
graphLayout: null,
|
||||
};
|
||||
}
|
||||
const state = globalThis[RUNTIME_DEBUG_STATE_KEY];
|
||||
state.graphLayout = snapshot && typeof snapshot === 'object'
|
||||
? {
|
||||
updatedAt: new Date().toISOString(),
|
||||
...cloneGraphLayoutDebugValue(snapshot, {}),
|
||||
}
|
||||
: null;
|
||||
state.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/** 兼容旧版 forceConfig(召回卡片等) */
|
||||
function layoutKeysFromForceConfig(fc) {
|
||||
@@ -194,6 +231,7 @@ export class GraphRenderer {
|
||||
const themeName = isLegacy ? options : (options?.theme || 'crimson');
|
||||
const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {});
|
||||
const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig);
|
||||
const runtimeConfig = isLegacy ? {} : (options?.runtimeConfig || {});
|
||||
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
@@ -203,9 +241,13 @@ export class GraphRenderer {
|
||||
this.colors = getNodeColors(themeName);
|
||||
this.themeName = themeName;
|
||||
this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride };
|
||||
this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
|
||||
this._userPovAliasSet = buildUserPovAliasNormalizedSet(
|
||||
isLegacy ? null : options?.userPovAliases,
|
||||
);
|
||||
this._nativeLayoutBridge = null;
|
||||
this._layoutSolveRevision = 0;
|
||||
this._lastLayoutDiagnostics = null;
|
||||
|
||||
this._regionPanels = [];
|
||||
this._lastGraph = null;
|
||||
@@ -253,7 +295,10 @@ export class GraphRenderer {
|
||||
* @param {{ userPovAliases?: string|string[]|object }} [layoutHints]
|
||||
*/
|
||||
loadGraph(graph, layoutHints = {}) {
|
||||
const loadStartedAt = performance.now();
|
||||
const prevSelectedId = this.selectedNode?.id || null;
|
||||
const solveRevision = this._nextLayoutSolveRevision();
|
||||
this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced');
|
||||
this._lastGraph = graph;
|
||||
this._lastLayoutHints = layoutHints && typeof layoutHints === 'object'
|
||||
? { ...layoutHints }
|
||||
@@ -303,13 +348,39 @@ export class GraphRenderer {
|
||||
strength: e.strength || 0.5,
|
||||
relation: e.relation || 'related',
|
||||
}));
|
||||
const prepareFinishedAt = performance.now();
|
||||
|
||||
const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
|
||||
this._regionPanels = this._computeRegionPanels(W, H, parts);
|
||||
this._layoutAllPartitions(parts);
|
||||
const layoutFinishedAt = performance.now();
|
||||
const neuralPlan = this._resolveNeuralSimulationPlan();
|
||||
const shouldTryNativeLayout = this._shouldTryNativeLayout(
|
||||
this.nodes.length,
|
||||
this.edges.length,
|
||||
);
|
||||
|
||||
let solvePath = neuralPlan.skip ? 'skipped' : 'js-main';
|
||||
let solveMs = 0;
|
||||
let nativeSolvePromise = null;
|
||||
|
||||
if (!neuralPlan.skip && neuralPlan.iterations > 0) {
|
||||
this._simulateNeuralWithinRegions(neuralPlan.iterations);
|
||||
if (shouldTryNativeLayout) {
|
||||
solvePath = 'native-worker-pending';
|
||||
nativeSolvePromise = this._simulateNeuralWithNativeBridge(
|
||||
neuralPlan.iterations,
|
||||
solveRevision,
|
||||
{
|
||||
loadStartedAt,
|
||||
prepareFinishedAt,
|
||||
layoutFinishedAt,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const solveStartedAt = performance.now();
|
||||
this._simulateNeuralWithinRegions(neuralPlan.iterations);
|
||||
solveMs = Math.max(0, performance.now() - solveStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
if (prevSelectedId) {
|
||||
@@ -318,6 +389,35 @@ export class GraphRenderer {
|
||||
|
||||
this._cancelAnim();
|
||||
this._render();
|
||||
|
||||
if (!nativeSolvePromise) {
|
||||
this._setLastLayoutDiagnostics({
|
||||
mode: solvePath,
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.edges.length,
|
||||
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
|
||||
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
|
||||
solveMs,
|
||||
totalMs: Math.max(0, performance.now() - loadStartedAt),
|
||||
at: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nativeSolvePromise
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
this._setLastLayoutDiagnostics({
|
||||
...result.diagnostics,
|
||||
at: Date.now(),
|
||||
});
|
||||
if (result.applied && this.enabled) {
|
||||
this._scheduleRender();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fail-open 路径由 bridge 内部控制
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,6 +429,33 @@ export class GraphRenderer {
|
||||
if (this.enabled) this._render();
|
||||
}
|
||||
|
||||
setRuntimeConfig(runtimeConfig = {}) {
|
||||
this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
|
||||
if (this._nativeLayoutBridge) {
|
||||
this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
|
||||
}
|
||||
}
|
||||
|
||||
getLastLayoutDiagnostics() {
|
||||
return this._lastLayoutDiagnostics
|
||||
? { ...this._lastLayoutDiagnostics }
|
||||
: null;
|
||||
}
|
||||
|
||||
_setLastLayoutDiagnostics(diagnostics = null) {
|
||||
this._lastLayoutDiagnostics = diagnostics && typeof diagnostics === 'object'
|
||||
? { ...diagnostics }
|
||||
: null;
|
||||
recordGraphLayoutDebugSnapshot(
|
||||
this._lastLayoutDiagnostics
|
||||
? {
|
||||
...this._lastLayoutDiagnostics,
|
||||
enabled: this.enabled !== false,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮指定节点
|
||||
*/
|
||||
@@ -351,7 +478,12 @@ export class GraphRenderer {
|
||||
if (!nextEnabled) this._clearCanvas();
|
||||
return;
|
||||
}
|
||||
this._nextLayoutSolveRevision();
|
||||
this._nativeLayoutBridge?.cancelPending?.('graph-renderer-state-changed');
|
||||
this.enabled = nextEnabled;
|
||||
if (this._lastLayoutDiagnostics) {
|
||||
this._setLastLayoutDiagnostics(this._lastLayoutDiagnostics);
|
||||
}
|
||||
this._cancelAnim();
|
||||
this.dragNode = null;
|
||||
this.isDragging = false;
|
||||
@@ -630,6 +762,190 @@ export class GraphRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
_nextLayoutSolveRevision() {
|
||||
this._layoutSolveRevision = Math.max(1, Number(this._layoutSolveRevision || 0) + 1);
|
||||
return this._layoutSolveRevision;
|
||||
}
|
||||
|
||||
_ensureNativeLayoutBridge() {
|
||||
if (this._nativeLayoutBridge) {
|
||||
this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
|
||||
return this._nativeLayoutBridge;
|
||||
}
|
||||
this._nativeLayoutBridge = new GraphNativeLayoutBridge(this.runtimeConfig);
|
||||
return this._nativeLayoutBridge;
|
||||
}
|
||||
|
||||
_shouldTryNativeLayout(nodeCount = 0, edgeCount = 0) {
|
||||
if (this.runtimeConfig.graphNativeForceDisable) return false;
|
||||
if (!this.runtimeConfig.graphUseNativeLayout) return false;
|
||||
const bridge = this._ensureNativeLayoutBridge();
|
||||
if (!bridge) return false;
|
||||
return bridge.shouldRunForGraph(nodeCount, edgeCount);
|
||||
}
|
||||
|
||||
_buildNativeLayoutPayload(iterations) {
|
||||
const nodeIndexById = new Map();
|
||||
const nodes = this.nodes.map((node, index) => {
|
||||
nodeIndexById.set(node.id, index);
|
||||
return {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
vx: node.vx,
|
||||
vy: node.vy,
|
||||
pinned: node.pinned === true,
|
||||
radius: this._nodeRadius(node),
|
||||
regionKey: node.regionKey,
|
||||
regionRect: node.regionRect
|
||||
? {
|
||||
x: node.regionRect.x,
|
||||
y: node.regionRect.y,
|
||||
w: node.regionRect.w,
|
||||
h: node.regionRect.h,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const edges = this.edges
|
||||
.map((edge) => {
|
||||
const from = nodeIndexById.get(edge.from?.id);
|
||||
const to = nodeIndexById.get(edge.to?.id);
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to) || from === to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
strength: edge.strength || 0.5,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
config: {
|
||||
iterations,
|
||||
repulsion: this.config.neuralRepulsion ?? 2800,
|
||||
springK: this.config.neuralSpringK ?? 0.048,
|
||||
damping: this.config.neuralDamping ?? 0.88,
|
||||
centerGravity: this.config.neuralCenterGravity ?? 0.014,
|
||||
minGap: this.config.neuralMinGap ?? 12,
|
||||
speedCap: 3.8,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_applyLayoutPositions(positions) {
|
||||
if (!(positions instanceof Float32Array)) return false;
|
||||
if (positions.length < this.nodes.length * 2) return false;
|
||||
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const node = this.nodes[i];
|
||||
if (!node || node.pinned) continue;
|
||||
node.x = positions[i * 2];
|
||||
node.y = positions[i * 2 + 1];
|
||||
node.vx = 0;
|
||||
node.vy = 0;
|
||||
this._clampNodeToRegion(node);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _simulateNeuralWithNativeBridge(iterations, solveRevision, timings = {}) {
|
||||
const loadStartedAt = Number(timings.loadStartedAt) || performance.now();
|
||||
const prepareFinishedAt = Number(timings.prepareFinishedAt) || loadStartedAt;
|
||||
const layoutFinishedAt = Number(timings.layoutFinishedAt) || prepareFinishedAt;
|
||||
|
||||
const bridge = this._ensureNativeLayoutBridge();
|
||||
const solveStartedAt = performance.now();
|
||||
let nativeResult = null;
|
||||
|
||||
try {
|
||||
nativeResult = await bridge.solveLayout(this._buildNativeLayoutPayload(iterations), {
|
||||
timeoutMs: this.runtimeConfig.graphNativeLayoutWorkerTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
nativeResult = {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: 'native-layout-bridge-error',
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (solveRevision !== this._layoutSolveRevision) {
|
||||
return {
|
||||
applied: false,
|
||||
diagnostics: {
|
||||
mode: 'native-stale',
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.edges.length,
|
||||
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
|
||||
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
|
||||
solveMs: Math.max(0, performance.now() - solveStartedAt),
|
||||
totalMs: Math.max(0, performance.now() - loadStartedAt),
|
||||
reason: 'stale-layout-result',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (nativeResult?.ok && this._applyLayoutPositions(nativeResult.positions)) {
|
||||
const workerElapsedMs = Number(nativeResult?.diagnostics?.elapsedMs);
|
||||
return {
|
||||
applied: true,
|
||||
diagnostics: {
|
||||
mode: nativeResult.usedNative ? 'rust-wasm-worker' : 'js-worker',
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.edges.length,
|
||||
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
|
||||
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
|
||||
solveMs: Math.max(0, performance.now() - solveStartedAt),
|
||||
workerSolveMs: Number.isFinite(workerElapsedMs)
|
||||
? Math.max(0, workerElapsedMs)
|
||||
: 0,
|
||||
totalMs: Math.max(0, performance.now() - loadStartedAt),
|
||||
reason: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.runtimeConfig.nativeEngineFailOpen) {
|
||||
return {
|
||||
applied: false,
|
||||
diagnostics: {
|
||||
mode: 'native-failed-hard',
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.edges.length,
|
||||
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
|
||||
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
|
||||
solveMs: Math.max(0, performance.now() - solveStartedAt),
|
||||
totalMs: Math.max(0, performance.now() - loadStartedAt),
|
||||
reason: nativeResult?.reason || 'native-layout-failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackStartedAt = performance.now();
|
||||
this._simulateNeuralWithinRegions(iterations);
|
||||
const fallbackSolveMs = Math.max(0, performance.now() - fallbackStartedAt);
|
||||
return {
|
||||
applied: true,
|
||||
diagnostics: {
|
||||
mode: 'js-fallback',
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.edges.length,
|
||||
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
|
||||
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
|
||||
solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs,
|
||||
fallbackSolveMs,
|
||||
totalMs: Math.max(0, performance.now() - loadStartedAt),
|
||||
reason: nativeResult?.reason || 'native-layout-failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分区内一次性力导向:斥力 + 同区边弹簧 + 弱向心,稳定后停止(无帧循环)
|
||||
*/
|
||||
@@ -1171,6 +1487,8 @@ export class GraphRenderer {
|
||||
}
|
||||
|
||||
if (this.nodes.length > 0 && this._regionPanels.length > 0) {
|
||||
this._nextLayoutSolveRevision();
|
||||
this._nativeLayoutBridge?.cancelPending?.('viewport-resize-layout-reset');
|
||||
this._rebuildLayoutForCurrentViewport(w, h);
|
||||
this._render();
|
||||
} else if (this._lastGraph) {
|
||||
@@ -1181,7 +1499,22 @@ export class GraphRenderer {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._nextLayoutSolveRevision();
|
||||
this._cancelAnim();
|
||||
this._nativeLayoutBridge?.dispose?.();
|
||||
this._nativeLayoutBridge = null;
|
||||
recordGraphLayoutDebugSnapshot(
|
||||
this._lastLayoutDiagnostics
|
||||
? {
|
||||
...this._lastLayoutDiagnostics,
|
||||
enabled: false,
|
||||
destroyed: true,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
destroyed: true,
|
||||
},
|
||||
);
|
||||
this._resizeObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +534,7 @@
|
||||
<div class="bme-graph-statusbar">
|
||||
<span><span class="bme-status-dot"></span><span id="bme-mobile-status-text">READY</span></span>
|
||||
<span id="bme-mobile-status-meta">NODES: 0 | EDGES: 0</span>
|
||||
<span id="bme-mobile-graph-layout-meta" class="bme-graph-layout-meta">LAYOUT: --</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -643,6 +644,7 @@
|
||||
><span id="bme-status-text">READY</span></span
|
||||
>
|
||||
<span id="bme-status-meta">NODES: 0 | EDGES: 0</span>
|
||||
<span id="bme-graph-layout-meta" class="bme-graph-layout-meta">LAYOUT: --</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
325
ui/panel.js
325
ui/panel.js
@@ -737,6 +737,36 @@ function _isGraphRenderingEnabled() {
|
||||
return graphRenderingEnabled !== false;
|
||||
}
|
||||
|
||||
function _buildGraphRuntimeConfig(settings = _getSettings?.() || {}) {
|
||||
return {
|
||||
graphUseNativeLayout: settings.graphUseNativeLayout === true,
|
||||
graphNativeLayoutThresholdNodes: Number.isFinite(
|
||||
Number(settings.graphNativeLayoutThresholdNodes),
|
||||
)
|
||||
? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdNodes)))
|
||||
: 280,
|
||||
graphNativeLayoutThresholdEdges: Number.isFinite(
|
||||
Number(settings.graphNativeLayoutThresholdEdges),
|
||||
)
|
||||
? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdEdges)))
|
||||
: 1600,
|
||||
graphNativeLayoutWorkerTimeoutMs: Number.isFinite(
|
||||
Number(settings.graphNativeLayoutWorkerTimeoutMs),
|
||||
)
|
||||
? Math.max(40, Math.floor(Number(settings.graphNativeLayoutWorkerTimeoutMs)))
|
||||
: 260,
|
||||
nativeEngineFailOpen: settings.nativeEngineFailOpen !== false,
|
||||
graphNativeForceDisable: settings.graphNativeForceDisable === true,
|
||||
};
|
||||
}
|
||||
|
||||
function _applyGraphRuntimeConfig(settings = _getSettings?.() || {}) {
|
||||
const runtimeConfig = _buildGraphRuntimeConfig(settings);
|
||||
graphRenderer?.setRuntimeConfig?.(runtimeConfig);
|
||||
mobileGraphRenderer?.setRuntimeConfig?.(runtimeConfig);
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
function _refreshGraphRenderToggleUi() {
|
||||
const enabled = _isGraphRenderingEnabled();
|
||||
const syncButton = (button) => {
|
||||
@@ -779,6 +809,7 @@ function _toggleGraphRenderingEnabled() {
|
||||
function _refreshVisibleGraphWorkspace({ force = false } = {}) {
|
||||
const visibleMode = _getVisibleGraphWorkspaceMode();
|
||||
if (visibleMode === "hidden") {
|
||||
_refreshGraphLayoutDiagnosticsUi();
|
||||
return { refreshed: false, reason: "hidden" };
|
||||
}
|
||||
|
||||
@@ -808,6 +839,8 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) {
|
||||
_refreshMobileSummaryFull();
|
||||
}
|
||||
|
||||
_refreshGraphLayoutDiagnosticsUi();
|
||||
|
||||
lastVisibleGraphRefreshToken = nextToken;
|
||||
lastVisibleGraphRefreshAt = Date.now();
|
||||
return {
|
||||
@@ -1159,6 +1192,7 @@ export function openPanel() {
|
||||
const graphOpts = {
|
||||
theme: themeName,
|
||||
userPovAliases: _hostUserPovAliasHintsForGraph(),
|
||||
runtimeConfig: _buildGraphRuntimeConfig(settings),
|
||||
};
|
||||
const canvas = document.getElementById("bme-graph-canvas");
|
||||
if (canvas && !graphRenderer && !isMobile) {
|
||||
@@ -1172,6 +1206,8 @@ export function openPanel() {
|
||||
mobileGraphRenderer.onNodeSelect = (node) => _showNodeDetail(node);
|
||||
}
|
||||
|
||||
_applyGraphRuntimeConfig(settings);
|
||||
|
||||
_applyGraphRenderEnabledState();
|
||||
|
||||
const activeTabId =
|
||||
@@ -1204,6 +1240,7 @@ export function updatePanelTheme(themeName) {
|
||||
|
||||
export function refreshLiveState() {
|
||||
if (!overlayEl?.classList.contains("active")) return;
|
||||
_applyGraphRuntimeConfig(_getSettings?.() || {});
|
||||
_refreshRuntimeStatus();
|
||||
|
||||
switch (currentTabId) {
|
||||
@@ -3982,6 +4019,89 @@ function _getActiveGraphRenderer() {
|
||||
return mobileGraphRenderer || graphRenderer;
|
||||
}
|
||||
|
||||
function _resolveVisibleGraphRenderer() {
|
||||
const visibleMode = _getVisibleGraphWorkspaceMode();
|
||||
if (visibleMode.startsWith("mobile:")) {
|
||||
return mobileGraphRenderer || graphRenderer;
|
||||
}
|
||||
if (visibleMode.startsWith("desktop:")) {
|
||||
return graphRenderer || mobileGraphRenderer;
|
||||
}
|
||||
return _getActiveGraphRenderer();
|
||||
}
|
||||
|
||||
function _formatGraphLayoutDiagnosticsText(diagnostics = null) {
|
||||
if (!diagnostics || typeof diagnostics !== "object") {
|
||||
return "LAYOUT: --";
|
||||
}
|
||||
|
||||
const modeRaw = String(
|
||||
diagnostics.mode || diagnostics.solver || "",
|
||||
).trim();
|
||||
const modeMap = {
|
||||
"js-main": "JS-main",
|
||||
"js-worker": "JS-worker",
|
||||
"rust-wasm-worker": "Rust-WASM",
|
||||
"js-fallback": "JS-fallback",
|
||||
skipped: "skipped",
|
||||
"native-stale": "stale",
|
||||
"native-failed-hard": "native-failed",
|
||||
};
|
||||
const modeLabel = modeMap[modeRaw] || modeRaw || "unknown";
|
||||
|
||||
const totalMs = Number(
|
||||
diagnostics.totalMs ?? diagnostics.solveMs ?? diagnostics.workerSolveMs,
|
||||
);
|
||||
const nodeCount = Number(diagnostics.nodeCount);
|
||||
const edgeCount = Number(diagnostics.edgeCount);
|
||||
|
||||
const parts = [`LAYOUT: ${modeLabel}`];
|
||||
if (Number.isFinite(totalMs)) {
|
||||
parts.push(`${Math.max(0, Math.round(totalMs))}ms`);
|
||||
}
|
||||
if (Number.isFinite(nodeCount) && Number.isFinite(edgeCount)) {
|
||||
parts.push(
|
||||
`${Math.max(0, Math.floor(nodeCount))}/${Math.max(
|
||||
0,
|
||||
Math.floor(edgeCount),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function _refreshGraphLayoutDiagnosticsUi() {
|
||||
const desktopMeta = document.getElementById("bme-graph-layout-meta");
|
||||
const mobileMeta = document.getElementById("bme-mobile-graph-layout-meta");
|
||||
if (!desktopMeta && !mobileMeta) return;
|
||||
|
||||
const renderer = _resolveVisibleGraphRenderer();
|
||||
const diagnostics = renderer?.getLastLayoutDiagnostics?.() || null;
|
||||
const text = _formatGraphLayoutDiagnosticsText(diagnostics);
|
||||
const title = diagnostics?.reason
|
||||
? `layout reason: ${String(diagnostics.reason).trim()}`
|
||||
: "";
|
||||
|
||||
if (desktopMeta) {
|
||||
desktopMeta.textContent = text;
|
||||
if (title) {
|
||||
desktopMeta.title = title;
|
||||
} else {
|
||||
desktopMeta.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
|
||||
if (mobileMeta) {
|
||||
mobileMeta.textContent = text;
|
||||
if (title) {
|
||||
mobileMeta.title = title;
|
||||
} else {
|
||||
mobileMeta.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _bindGraphControls() {
|
||||
document
|
||||
.getElementById("bme-graph-render-toggle")
|
||||
@@ -7290,6 +7410,8 @@ function _getMessageTraceWorkspaceState(settings = _getSettings?.() || {}) {
|
||||
panelDebug,
|
||||
runtimeDebug,
|
||||
recallInjection: runtimeDebug?.injections?.recall || null,
|
||||
graphLayout: runtimeDebug?.graphLayout || null,
|
||||
persistDelta: runtimeDebug?.graphPersistence?.persistDelta || null,
|
||||
messageTrace: runtimeDebug?.messageTrace || null,
|
||||
recallLlmRequest: runtimeDebug?.taskLlmRequests?.recall || null,
|
||||
recallPromptBuild: runtimeDebug?.taskPromptBuilds?.recall || null,
|
||||
@@ -7313,6 +7435,8 @@ function _refreshMessageTraceWorkspace(settings = _getSettings?.() || {}) {
|
||||
function _renderMessageTraceWorkspace(state) {
|
||||
const updatedCandidates = [
|
||||
state.recallInjection?.updatedAt,
|
||||
state.graphLayout?.updatedAt,
|
||||
state.persistDelta?.updatedAt,
|
||||
state.recallLlmRequest?.updatedAt,
|
||||
state.extractLlmRequest?.updatedAt,
|
||||
state.extractPromptBuild?.updatedAt,
|
||||
@@ -7345,6 +7469,12 @@ function _renderMessageTraceWorkspace(state) {
|
||||
<div class="bme-config-card">
|
||||
${_renderAiMonitorCognitionCard(state)}
|
||||
</div>
|
||||
<div class="bme-config-card">
|
||||
${_renderGraphLayoutTraceCard(state)}
|
||||
</div>
|
||||
<div class="bme-config-card">
|
||||
${_renderPersistDeltaTraceCard(state)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -7808,6 +7938,180 @@ function _renderAiMonitorCognitionCard(state) {
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderGraphLayoutTraceCard(state) {
|
||||
const layout = state.graphLayout || null;
|
||||
if (!layout) {
|
||||
return `
|
||||
<div class="bme-config-card-title">图布局 / Native 诊断</div>
|
||||
<div class="bme-config-help">
|
||||
还没有图布局诊断快照。打开图谱页并触发一次布局后,这里会显示实际执行路径、耗时和 native 模块来源。
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const mode = String(layout.mode || layout.solver || 'unknown').trim() || 'unknown';
|
||||
const moduleSource = String(layout.moduleSource || '').trim() || '—';
|
||||
const reason = String(layout.reason || '').trim() || '—';
|
||||
const nativeLoadError = String(layout.nativeLoadError || '').trim();
|
||||
|
||||
return `
|
||||
<div class="bme-config-card-head">
|
||||
<div>
|
||||
<div class="bme-config-card-title">图布局 / Native 诊断</div>
|
||||
<div class="bme-config-card-subtitle">
|
||||
记录最近一次图布局走了哪条路径,以及 native 模块是 wasm-pack 产物还是 fallback loader。
|
||||
</div>
|
||||
</div>
|
||||
<span class="bme-task-pill">${_escHtml(_formatTaskProfileTime(layout.updatedAt || layout.at))}</span>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv">
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>布局路径</span>
|
||||
<strong>${_escHtml(mode)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>节点 / 边</span>
|
||||
<strong>${_escHtml(`${Number(layout.nodeCount || 0)} / ${Number(layout.edgeCount || 0)}`)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>总耗时</span>
|
||||
<strong>${_escHtml(_formatDurationMs(layout.totalMs))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>求解耗时</span>
|
||||
<strong>${_escHtml(_formatDurationMs(layout.solveMs || layout.workerSolveMs))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>迭代次数</span>
|
||||
<strong>${_escHtml(String(layout.iterations || '—'))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>Native 来源</span>
|
||||
<strong>${_escHtml(moduleSource)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>状态原因</span>
|
||||
<strong>${_escHtml(reason)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
${_renderMessageTraceTextBlock(
|
||||
'Native load error',
|
||||
nativeLoadError,
|
||||
'当前没有 native load error。',
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function _formatPersistDeltaGateReasonText(reasons = []) {
|
||||
const labels = {
|
||||
"below-record-threshold": "记录数不足",
|
||||
"below-structural-delta-threshold": "结构变化不足",
|
||||
"below-serialized-chars-threshold": "序列化体积不足",
|
||||
};
|
||||
const normalized = Array.isArray(reasons)
|
||||
? reasons
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (!normalized.length) return "—";
|
||||
return normalized.map((item) => labels[item] || item).join(" · ");
|
||||
}
|
||||
|
||||
function _formatPersistDeltaGateText(diagnostics = null) {
|
||||
if (!diagnostics || typeof diagnostics !== "object") return "—";
|
||||
if (diagnostics.requestedNative !== true) return "未请求 native";
|
||||
if (diagnostics.nativeForceDisabled === true) return "已强制关闭";
|
||||
if (diagnostics.gateAllowed === true) return "通过";
|
||||
return `已拦截 · ${_formatPersistDeltaGateReasonText(diagnostics.gateReasons)}`;
|
||||
}
|
||||
|
||||
function _renderPersistDeltaTraceCard(state) {
|
||||
const diagnostics = state.persistDelta || null;
|
||||
if (!diagnostics) {
|
||||
return `
|
||||
<div class="bme-config-card-title">Persist Delta / Native 诊断</div>
|
||||
<div class="bme-config-help">
|
||||
还没有 persist delta 诊断快照。等图谱完成一次 IndexedDB 写回后,这里会显示 gate、执行路径、耗时和 fallback 原因。
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const moduleSource = String(diagnostics.moduleSource || "").trim() || "—";
|
||||
const fallbackReason = String(diagnostics.fallbackReason || "").trim();
|
||||
const errorText = String(
|
||||
diagnostics.moduleError || diagnostics.preloadError || diagnostics.nativeError || "",
|
||||
).trim();
|
||||
const payloadCharsText = diagnostics.combinedSerializedChars
|
||||
? `${Number(diagnostics.combinedSerializedChars || 0)} / ${Number(diagnostics.minCombinedSerializedChars || 0)}`
|
||||
: "—";
|
||||
|
||||
return `
|
||||
<div class="bme-config-card-head">
|
||||
<div>
|
||||
<div class="bme-config-card-title">Persist Delta / Native 诊断</div>
|
||||
<div class="bme-config-card-subtitle">
|
||||
记录最近一次图谱增量写回的 gate 判定、真实执行路径,以及 native preload / fallback 情况。
|
||||
</div>
|
||||
</div>
|
||||
<span class="bme-task-pill">${_escHtml(_formatTaskProfileTime(diagnostics.updatedAt))}</span>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv">
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>执行路径</span>
|
||||
<strong>${_escHtml(String(diagnostics.path || "—"))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>Native Gate</span>
|
||||
<strong>${_escHtml(_formatPersistDeltaGateText(diagnostics))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>快照记录数</span>
|
||||
<strong>${_escHtml(`${Number(diagnostics.beforeRecordCount || 0)} → ${Number(diagnostics.afterRecordCount || 0)}`)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>结构变化量</span>
|
||||
<strong>${_escHtml(String(diagnostics.structuralDelta ?? "—"))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>Payload chars</span>
|
||||
<strong>${_escHtml(payloadCharsText)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>总耗时</span>
|
||||
<strong>${_escHtml(_formatDurationMs(diagnostics.totalMs || diagnostics.buildMs))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>构建耗时</span>
|
||||
<strong>${_escHtml(_formatDurationMs(diagnostics.buildMs))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>Preload</span>
|
||||
<strong>${_escHtml(String(diagnostics.preloadStatus || "—"))}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>Native 来源</span>
|
||||
<strong>${_escHtml(moduleSource)}</strong>
|
||||
</div>
|
||||
<div class="bme-ai-monitor-kv__row">
|
||||
<span>增量规模</span>
|
||||
<strong>${_escHtml(
|
||||
`${Number(diagnostics.upsertNodeCount || 0)}N / ${Number(diagnostics.upsertEdgeCount || 0)}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(diagnostics.deleteEdgeCount || 0)}DE`,
|
||||
)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
${_renderMessageTraceTextBlock(
|
||||
"Fallback reason",
|
||||
fallbackReason,
|
||||
"这次没有发生 native fallback。",
|
||||
)}
|
||||
${_renderMessageTraceTextBlock(
|
||||
"Preload / native error",
|
||||
errorText,
|
||||
"当前没有 preload / native error。",
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderMessageTraceTextBlock(title, text, emptyText = "暂无内容") {
|
||||
const normalized = String(text || "").trim();
|
||||
return `
|
||||
@@ -9016,6 +9320,8 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
|
||||
`;
|
||||
}
|
||||
|
||||
const persistDelta = graphPersistence.persistDelta || null;
|
||||
|
||||
return `
|
||||
<div class="bme-config-card-head">
|
||||
<div>
|
||||
@@ -9085,6 +9391,22 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
|
||||
: "—",
|
||||
)}</span>
|
||||
</div>
|
||||
<div class="bme-debug-kv-item">
|
||||
<span class="bme-debug-kv-key">Persist Delta 路径</span>
|
||||
<span class="bme-debug-kv-value">${_escHtml(String(persistDelta?.path || "—"))}</span>
|
||||
</div>
|
||||
<div class="bme-debug-kv-item">
|
||||
<span class="bme-debug-kv-key">Persist Native Gate</span>
|
||||
<span class="bme-debug-kv-value">${_escHtml(_formatPersistDeltaGateText(persistDelta))}</span>
|
||||
</div>
|
||||
<div class="bme-debug-kv-item">
|
||||
<span class="bme-debug-kv-key">Persist Delta 耗时</span>
|
||||
<span class="bme-debug-kv-value">${_escHtml(_formatDurationMs(persistDelta?.totalMs || persistDelta?.buildMs))}</span>
|
||||
</div>
|
||||
<div class="bme-debug-kv-item">
|
||||
<span class="bme-debug-kv-key">Persist Native 来源</span>
|
||||
<span class="bme-debug-kv-value">${_escHtml(String(persistDelta?.moduleSource || "—"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
${_renderDebugDetails("图谱持久化详情", graphPersistence)}
|
||||
`;
|
||||
@@ -10887,6 +11209,7 @@ function _getGraphPersistenceSnapshot() {
|
||||
lastBackupRollbackAt: 0,
|
||||
lastBackupFilename: "",
|
||||
lastSyncError: "",
|
||||
persistDelta: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11182,6 +11505,8 @@ function _refreshGraphAvailabilityState() {
|
||||
if (mobileOverlayText) {
|
||||
mobileOverlayText.textContent = overlayLabel;
|
||||
}
|
||||
|
||||
_refreshGraphLayoutDiagnosticsUi();
|
||||
}
|
||||
|
||||
function _formatCloudTimeLabel(timestamp) {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function createGraphPersistenceState() {
|
||||
syncDirtyReason: "",
|
||||
lastSyncError: "",
|
||||
dualWriteLastResult: null,
|
||||
persistDelta: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user