mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
247 lines
7.2 KiB
JavaScript
247 lines
7.2 KiB
JavaScript
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();
|
|
}
|
|
}
|