mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-29 19:53:47 +02:00
use worker
This commit is contained in:
109
src/views/Charts/__tests__/graphLayoutWorker.test.js
Normal file
109
src/views/Charts/__tests__/graphLayoutWorker.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
class FakeGraph {
|
||||
constructor() {
|
||||
this._nodes = new Map();
|
||||
this._edges = [];
|
||||
}
|
||||
|
||||
addNode(id, attrs = {}) {
|
||||
this._nodes.set(id, { ...attrs });
|
||||
}
|
||||
|
||||
addEdgeWithKey(key, source, target, attrs = {}) {
|
||||
if (!this._nodes.has(source) || !this._nodes.has(target)) {
|
||||
throw new Error('missing node');
|
||||
}
|
||||
this._edges.push({ key, source, target, attrs: { ...attrs } });
|
||||
}
|
||||
|
||||
get order() {
|
||||
return this._nodes.size;
|
||||
}
|
||||
|
||||
forEachNode(cb) {
|
||||
for (const [id, attrs] of this._nodes.entries()) cb(id, attrs);
|
||||
}
|
||||
|
||||
mergeNodeAttributes(node, attrs) {
|
||||
this._nodes.set(node, { ...this._nodes.get(node), ...attrs });
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('graphology', () => ({ default: FakeGraph }));
|
||||
vi.mock('graphology-layout-forceatlas2', () => ({
|
||||
default: {
|
||||
inferSettings: vi.fn(() => ({ gravity: 1 })),
|
||||
assign: vi.fn((graph) => {
|
||||
graph.forEachNode((id, attrs) => {
|
||||
graph.mergeNodeAttributes(id, {
|
||||
x: Number.isFinite(attrs.x) ? attrs.x + 1 : 1,
|
||||
y: Number.isFinite(attrs.y) ? attrs.y + 1 : 1
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
}));
|
||||
vi.mock('graphology-layout-noverlap', () => ({
|
||||
default: {
|
||||
assign: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
function setupWorkerHarness() {
|
||||
const sent = [];
|
||||
let handler = null;
|
||||
|
||||
globalThis.self = {
|
||||
addEventListener: vi.fn((event, cb) => {
|
||||
if (event === 'message') handler = cb;
|
||||
}),
|
||||
postMessage: vi.fn((payload) => {
|
||||
sent.push(payload);
|
||||
})
|
||||
};
|
||||
|
||||
return {
|
||||
sent,
|
||||
dispatch: (data) => handler?.({ data })
|
||||
};
|
||||
}
|
||||
|
||||
describe('graphLayoutWorker message protocol', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test('returns positions with the same requestId on success', async () => {
|
||||
const harness = setupWorkerHarness();
|
||||
await import('../graphLayoutWorker.js');
|
||||
|
||||
harness.dispatch({
|
||||
requestId: 11,
|
||||
nodes: [{ id: 'n1', attributes: { x: 0, y: 0 } }, { id: 'n2', attributes: { x: 2, y: 2 } }],
|
||||
edges: [{ key: 'n1__n2', source: 'n1', target: 'n2', attributes: {} }],
|
||||
settings: { layoutIterations: 300, layoutSpacing: 60, deltaSpacing: 0, reinitialize: false }
|
||||
});
|
||||
|
||||
expect(harness.sent).toHaveLength(1);
|
||||
expect(harness.sent[0].requestId).toBe(11);
|
||||
expect(harness.sent[0].positions.n1).toBeTruthy();
|
||||
expect(harness.sent[0].positions.n2).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns error with requestId when layout throws', async () => {
|
||||
const harness = setupWorkerHarness();
|
||||
await import('../graphLayoutWorker.js');
|
||||
|
||||
harness.dispatch({
|
||||
requestId: 12,
|
||||
nodes: [{ id: 'n1', attributes: { x: 0, y: 0 } }],
|
||||
edges: [{ key: 'n1__n2', source: 'n1', target: 'n2', attributes: {} }],
|
||||
settings: { layoutIterations: 300, layoutSpacing: 60, deltaSpacing: 0, reinitialize: false }
|
||||
});
|
||||
|
||||
expect(harness.sent).toHaveLength(1);
|
||||
expect(harness.sent[0].requestId).toBe(12);
|
||||
expect(harness.sent[0].error).toContain('missing node');
|
||||
});
|
||||
});
|
||||
@@ -263,9 +263,9 @@
|
||||
import EdgeCurveProgram from '@sigma/edge-curve';
|
||||
import Graph from 'graphology';
|
||||
import Sigma from 'sigma';
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||
import louvain from 'graphology-communities-louvain';
|
||||
import noverlap from 'graphology-layout-noverlap';
|
||||
|
||||
import GraphLayoutWorker from '../graphLayoutWorker.js?worker';
|
||||
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
@@ -326,6 +326,39 @@
|
||||
let pendingRender = null;
|
||||
let pendingLayoutUpdate = null;
|
||||
let lastMutualMap = null;
|
||||
let layoutWorker = null;
|
||||
let layoutRequestId = 0;
|
||||
const layoutResolvers = new Map();
|
||||
let layoutQueue = Promise.resolve();
|
||||
|
||||
function getLayoutWorker() {
|
||||
if (!layoutWorker) {
|
||||
layoutWorker = new GraphLayoutWorker();
|
||||
layoutWorker.addEventListener('message', handleLayoutMessage);
|
||||
layoutWorker.addEventListener('error', handleLayoutError);
|
||||
}
|
||||
return layoutWorker;
|
||||
}
|
||||
|
||||
function handleLayoutMessage(event) {
|
||||
const { requestId, positions, error } = event.data;
|
||||
const resolver = layoutResolvers.get(requestId);
|
||||
if (!resolver) return;
|
||||
layoutResolvers.delete(requestId);
|
||||
if (error) {
|
||||
resolver.reject(new Error(error));
|
||||
} else {
|
||||
resolver.resolve(positions);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLayoutError(err) {
|
||||
// Reject all pending requests on worker-level error
|
||||
for (const [id, resolver] of layoutResolvers) {
|
||||
resolver.reject(err);
|
||||
layoutResolvers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const LAYOUT_ITERATIONS_MIN = 300;
|
||||
const LAYOUT_ITERATIONS_MAX = 1500;
|
||||
@@ -507,9 +540,15 @@
|
||||
localStorage.setItem(EXCLUDED_FRIENDS_KEY, JSON.stringify(excludedFriendIds.value));
|
||||
}
|
||||
|
||||
watch(excludedFriendIds, () => {
|
||||
watch(excludedFriendIds, async () => {
|
||||
saveExcludedFriends();
|
||||
if (lastMutualMap) applyGraph(lastMutualMap);
|
||||
if (lastMutualMap) {
|
||||
try {
|
||||
await applyGraph(lastMutualMap);
|
||||
} catch (err) {
|
||||
console.error('[MutualNetworkGraph] Failed to apply graph after exclude change', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const excludePickerGroups = computed(() => {
|
||||
@@ -602,6 +641,14 @@
|
||||
sigmaInstance = null;
|
||||
}
|
||||
currentGraph = null;
|
||||
if (layoutWorker) {
|
||||
for (const [id, resolver] of layoutResolvers) {
|
||||
resolver.reject(new Error('Component unmounted'));
|
||||
layoutResolvers.delete(id);
|
||||
}
|
||||
layoutWorker.terminate();
|
||||
layoutWorker = null;
|
||||
}
|
||||
if (mutualGraphResizeObserver) mutualGraphResizeObserver.disconnect();
|
||||
});
|
||||
|
||||
@@ -624,71 +671,72 @@
|
||||
return text.length > MAX_LABEL_NAME_LENGTH ? `${text.slice(0, MAX_LABEL_NAME_LENGTH)}…` : text;
|
||||
}
|
||||
|
||||
function initPositions(graph) {
|
||||
const n = graph.order;
|
||||
const radius = Math.max(50, Math.sqrt(n) * 30);
|
||||
graph.forEachNode((node) => {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * radius;
|
||||
graph.mergeNodeAttributes(node, {
|
||||
x: Math.cos(a) * r,
|
||||
y: Math.sin(a) * r
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
const normalized = Number.isFinite(value) ? value : min;
|
||||
return Math.min(max, Math.max(min, normalized));
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function jitterPositions(graph, magnitude) {
|
||||
graph.forEachNode((node, attrs) => {
|
||||
if (!Number.isFinite(attrs.x) || !Number.isFinite(attrs.y)) return;
|
||||
graph.mergeNodeAttributes(node, {
|
||||
x: attrs.x + (Math.random() - 0.5) * magnitude,
|
||||
y: attrs.y + (Math.random() - 0.5) * magnitude
|
||||
});
|
||||
/**
|
||||
* @param {Graph} graph
|
||||
* @returns {{ nodes: Array, edges: Array }}
|
||||
*/
|
||||
function serializeGraph(graph) {
|
||||
const nodes = [];
|
||||
graph.forEachNode((id, attributes) => {
|
||||
nodes.push({ id, attributes: { ...attributes } });
|
||||
});
|
||||
const edges = [];
|
||||
graph.forEachEdge((key, attributes, source, target) => {
|
||||
edges.push({ key, source, target, attributes: { ...attributes } });
|
||||
});
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
function runLayout(graph, { reinitialize } = {}) {
|
||||
if (reinitialize) initPositions(graph);
|
||||
|
||||
const iterations = clampNumber(layoutSettings.layoutIterations, LAYOUT_ITERATIONS_MIN, LAYOUT_ITERATIONS_MAX);
|
||||
/**
|
||||
* Run ForceAtlas2 + Noverlap layout in a Web Worker.
|
||||
* Requests are serialized: a new call waits for the previous one to finish,
|
||||
* preventing concurrent callbacks from stepping on each other.
|
||||
* @param {Graph} graph
|
||||
* @param {object} options
|
||||
* @param {boolean} [options.reinitialize]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function runLayout(graph, { reinitialize } = {}) {
|
||||
const spacing = clampNumber(layoutSettings.layoutSpacing, LAYOUT_SPACING_MIN, LAYOUT_SPACING_MAX);
|
||||
const t = (spacing - LAYOUT_SPACING_MIN) / (LAYOUT_SPACING_MAX - LAYOUT_SPACING_MIN);
|
||||
const clampedT = clampNumber(t, 0, 1);
|
||||
const deltaSpacing = spacing - lastLayoutSpacing;
|
||||
lastLayoutSpacing = spacing;
|
||||
|
||||
const inferred = forceAtlas2.inferSettings ? forceAtlas2.inferSettings(graph) : {};
|
||||
const settings = {
|
||||
...inferred,
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.8,
|
||||
strongGravityMode: true,
|
||||
gravity: lerp(1.6, 0.6, clampedT),
|
||||
scalingRatio: spacing,
|
||||
slowDown: 2
|
||||
};
|
||||
const { nodes, edges } = serializeGraph(graph);
|
||||
const worker = getLayoutWorker();
|
||||
const id = ++layoutRequestId;
|
||||
|
||||
if (Math.abs(deltaSpacing) >= 8) jitterPositions(graph, lerp(0.5, 2.0, clampedT));
|
||||
// Serialize: wait for any in-flight layout to finish first
|
||||
const task = layoutQueue.then(async () => {
|
||||
const positions = await new Promise((resolve, reject) => {
|
||||
layoutResolvers.set(id, { resolve, reject });
|
||||
worker.postMessage({
|
||||
requestId: id,
|
||||
nodes,
|
||||
edges,
|
||||
settings: {
|
||||
layoutIterations: layoutSettings.layoutIterations,
|
||||
layoutSpacing: spacing,
|
||||
deltaSpacing,
|
||||
reinitialize: reinitialize ?? false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
forceAtlas2.assign(graph, { iterations, settings });
|
||||
const noverlapIterations = clampNumber(Math.round(Math.sqrt(graph.order) * 6), 200, 600);
|
||||
noverlap.assign(graph, {
|
||||
maxIterations: noverlapIterations,
|
||||
settings: {
|
||||
ratio: lerp(1.05, 1.35, clampedT),
|
||||
margin: lerp(1, 8, clampedT)
|
||||
for (const [nodeId, pos] of Object.entries(positions)) {
|
||||
if (graph.hasNode(nodeId)) {
|
||||
graph.mergeNodeAttributes(nodeId, { x: pos.x, y: pos.y });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the queue going even if this request fails
|
||||
layoutQueue = task.catch(() => {});
|
||||
return task;
|
||||
}
|
||||
|
||||
function applyEdgeCurvature(graph) {
|
||||
@@ -755,14 +803,18 @@
|
||||
function scheduleLayoutUpdate({ runLayout: shouldRunLayout }) {
|
||||
if (!currentGraph) return;
|
||||
if (pendingLayoutUpdate) clearTimeout(pendingLayoutUpdate);
|
||||
pendingLayoutUpdate = setTimeout(() => {
|
||||
pendingLayoutUpdate = setTimeout(async () => {
|
||||
pendingLayoutUpdate = null;
|
||||
applyEdgeCurvature(currentGraph);
|
||||
if (shouldRunLayout) {
|
||||
runLayout(currentGraph, { reinitialize: false });
|
||||
applyCommunitySeparation(currentGraph);
|
||||
try {
|
||||
applyEdgeCurvature(currentGraph);
|
||||
if (shouldRunLayout) {
|
||||
await runLayout(currentGraph, { reinitialize: false });
|
||||
applyCommunitySeparation(currentGraph);
|
||||
}
|
||||
renderGraph(currentGraph);
|
||||
} catch (err) {
|
||||
console.error('[MutualNetworkGraph] Layout update failed', err);
|
||||
}
|
||||
renderGraph(currentGraph);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -780,7 +832,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function buildGraphFromMutualMap(mutualMap) {
|
||||
async function buildGraphFromMutualMap(mutualMap) {
|
||||
const graph = new Graph({
|
||||
type: 'undirected',
|
||||
multi: false,
|
||||
@@ -836,7 +888,7 @@
|
||||
});
|
||||
|
||||
if (graph.order > 1) {
|
||||
runLayout(graph, { reinitialize: true });
|
||||
await runLayout(graph, { reinitialize: true });
|
||||
assignCommunitiesAndColors(graph);
|
||||
applyCommunitySeparation(graph);
|
||||
applyEdgeCurvature(graph);
|
||||
@@ -1023,9 +1075,9 @@
|
||||
sigmaInstance.refresh();
|
||||
}
|
||||
|
||||
function applyGraph(mutualMap) {
|
||||
async function applyGraph(mutualMap) {
|
||||
lastMutualMap = mutualMap;
|
||||
const graph = buildGraphFromMutualMap(mutualMap);
|
||||
const graph = await buildGraphFromMutualMap(mutualMap);
|
||||
currentGraph = graph;
|
||||
renderGraph(graph);
|
||||
}
|
||||
@@ -1074,7 +1126,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
applyGraph(mutualMap);
|
||||
await applyGraph(mutualMap);
|
||||
chartsStore.markMutualGraphLoaded({ notify: false });
|
||||
fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size);
|
||||
status.friendSignature = totalFriends.value;
|
||||
@@ -1122,7 +1174,11 @@
|
||||
if (isFetching.value || isOptOut.value) return;
|
||||
const mutualMap = await chartsStore.fetchMutualGraph();
|
||||
if (!mutualMap) return;
|
||||
applyGraph(mutualMap);
|
||||
try {
|
||||
await applyGraph(mutualMap);
|
||||
} catch (err) {
|
||||
console.error('[MutualNetworkGraph] Failed to apply graph after fetch', err);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFetch() {
|
||||
|
||||
166
src/views/Charts/graphLayoutWorker.js
Normal file
166
src/views/Charts/graphLayoutWorker.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Web Worker for graph layout computation.
|
||||
*
|
||||
* Runs ForceAtlas2 and Noverlap layout algorithms off the main thread
|
||||
* to prevent UI freezing during heavy graph layout calculations.
|
||||
*
|
||||
* Protocol:
|
||||
* Main → Worker: { requestId, nodes, edges, settings }
|
||||
* Worker → Main: { requestId, positions } | { requestId, error }
|
||||
*/
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2';
|
||||
import noverlap from 'graphology-layout-noverlap';
|
||||
import Graph from 'graphology';
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max.
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampNumber(value, min, max) {
|
||||
const normalized = Number.isFinite(value) ? value : min;
|
||||
return Math.min(max, Math.max(min, normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @param {number} t
|
||||
* @returns {number}
|
||||
*/
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add small random offsets to node positions.
|
||||
* @param {Graph} graph
|
||||
* @param {number} magnitude
|
||||
*/
|
||||
function jitterPositions(graph, magnitude) {
|
||||
graph.forEachNode((node, attrs) => {
|
||||
if (!Number.isFinite(attrs.x) || !Number.isFinite(attrs.y)) return;
|
||||
graph.mergeNodeAttributes(node, {
|
||||
x: attrs.x + (Math.random() - 0.5) * magnitude,
|
||||
y: attrs.y + (Math.random() - 0.5) * magnitude
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign random initial positions to graph nodes.
|
||||
* @param {Graph} graph
|
||||
*/
|
||||
function initPositions(graph) {
|
||||
const n = graph.order;
|
||||
const radius = Math.max(50, Math.sqrt(n) * 30);
|
||||
graph.forEachNode((node) => {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * radius;
|
||||
graph.mergeNodeAttributes(node, {
|
||||
x: Math.cos(a) * r,
|
||||
y: Math.sin(a) * r
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const LAYOUT_SPACING_MIN = 8;
|
||||
const LAYOUT_SPACING_MAX = 240;
|
||||
const LAYOUT_ITERATIONS_MIN = 300;
|
||||
const LAYOUT_ITERATIONS_MAX = 1500;
|
||||
|
||||
/**
|
||||
* Run ForceAtlas2 + Noverlap layout on a serialized graph.
|
||||
* @param {object} data - Message data from main thread
|
||||
*/
|
||||
function runLayout(data) {
|
||||
const { nodes, edges, settings } = data;
|
||||
|
||||
// Reconstruct graph in worker
|
||||
const graph = new Graph({
|
||||
type: 'undirected',
|
||||
multi: false,
|
||||
allowSelfLoops: false
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
graph.addNode(node.id, node.attributes);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
graph.addEdgeWithKey(edge.key, edge.source, edge.target, edge.attributes);
|
||||
}
|
||||
|
||||
const reinitialize = settings.reinitialize ?? false;
|
||||
if (reinitialize) {
|
||||
initPositions(graph);
|
||||
}
|
||||
|
||||
const iterations = clampNumber(
|
||||
settings.layoutIterations,
|
||||
LAYOUT_ITERATIONS_MIN,
|
||||
LAYOUT_ITERATIONS_MAX
|
||||
);
|
||||
const spacing = clampNumber(
|
||||
settings.layoutSpacing,
|
||||
LAYOUT_SPACING_MIN,
|
||||
LAYOUT_SPACING_MAX
|
||||
);
|
||||
const t = (spacing - LAYOUT_SPACING_MIN) / (LAYOUT_SPACING_MAX - LAYOUT_SPACING_MIN);
|
||||
const clampedT = clampNumber(t, 0, 1);
|
||||
const deltaSpacing = settings.deltaSpacing ?? 0;
|
||||
|
||||
// ForceAtlas2
|
||||
const inferred = forceAtlas2.inferSettings
|
||||
? forceAtlas2.inferSettings(graph)
|
||||
: {};
|
||||
const fa2Settings = {
|
||||
...inferred,
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.8,
|
||||
strongGravityMode: true,
|
||||
gravity: lerp(1.6, 0.6, clampedT),
|
||||
scalingRatio: spacing,
|
||||
slowDown: 2
|
||||
};
|
||||
|
||||
if (Math.abs(deltaSpacing) >= 8) {
|
||||
jitterPositions(graph, lerp(0.5, 2.0, clampedT));
|
||||
}
|
||||
|
||||
forceAtlas2.assign(graph, { iterations, settings: fa2Settings });
|
||||
|
||||
// Noverlap
|
||||
const noverlapIterations = clampNumber(
|
||||
Math.round(Math.sqrt(graph.order) * 6),
|
||||
200,
|
||||
600
|
||||
);
|
||||
noverlap.assign(graph, {
|
||||
maxIterations: noverlapIterations,
|
||||
settings: {
|
||||
ratio: lerp(1.05, 1.35, clampedT),
|
||||
margin: lerp(1, 8, clampedT)
|
||||
}
|
||||
});
|
||||
|
||||
// Extract positions
|
||||
const positions = {};
|
||||
graph.forEachNode((node, attrs) => {
|
||||
positions[node] = { x: attrs.x, y: attrs.y };
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const { requestId } = event.data;
|
||||
try {
|
||||
const positions = runLayout(event.data);
|
||||
self.postMessage({ requestId, positions });
|
||||
} catch (err) {
|
||||
self.postMessage({ requestId, error: err.message });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user