use worker

This commit is contained in:
pa
2026-03-13 16:00:33 +09:00
parent 4d131703e7
commit ff999896b2
7 changed files with 1141 additions and 176 deletions
+121 -65
View File
@@ -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() {