Skip to content

Commit 9748ede

Browse files
amcdnlclaude
andcommitted
Add web worker support for layout calculations
- Add `webWorkers` prop to GraphCanvas for offloading layout computations - Implement ForceAtlas2 worker using graphology's native FA2Layout - Add custom d3-force worker for forceDirected2d/3d layouts - Create layout adapters (createGraphPositionAdapter, createPositionMapAdapter) that implement LayoutStrategy for unified graph transformation - Use inline worker imports (?worker&inline) for npm distribution compatibility - Add WorkerLayout stories for testing worker vs main thread performance The webWorkers prop moves heavy layout calculations off the main thread, improving UI responsiveness especially for large graphs (500+ nodes). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent f9f8264 commit 9748ede

File tree

12 files changed

+1380
-5
lines changed

12 files changed

+1380
-5
lines changed

src/GraphCanvas/GraphCanvas.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ export interface GraphCanvasProps extends Omit<GraphSceneProps, 'theme'> {
8787
* Whether to aggregate edges with the same source and target.
8888
*/
8989
aggregateEdges?: boolean;
90+
91+
/**
92+
* Enable web workers for layout calculations.
93+
* This moves heavy layout computations off the main thread,
94+
* improving UI responsiveness especially for large graphs.
95+
* Default: false
96+
*/
97+
webWorkers?: boolean;
9098
}
9199

92100
export type GraphCanvasRef = Omit<GraphSceneRef, 'graph' | 'renderScene'> &

src/GraphScene.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ export interface GraphSceneProps {
182182
*/
183183
aggregateEdges?: boolean;
184184

185+
/**
186+
* Enable web workers for layout calculations.
187+
* This moves heavy layout computations off the main thread,
188+
* improving UI responsiveness especially for large graphs.
189+
* Default: false
190+
*/
191+
webWorkers?: boolean;
192+
185193
/**
186194
* When a node was clicked.
187195
*/

src/layout/graphPositionAdapter.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Layout adapter that reads positions directly from graphology node attributes.
3+
* Used when a worker (like FA2Layout) has already computed positions
4+
* and stored them as x/y attributes on the graph nodes.
5+
*/
6+
7+
import type Graph from 'graphology';
8+
import type { DragReferences } from '../store';
9+
import type { InternalGraphPosition, InternalVector3 } from '../types';
10+
import type { LayoutStrategy } from './types';
11+
12+
export interface GraphPositionAdapterOptions {
13+
graph: Graph;
14+
drags?: DragReferences;
15+
/** Whether the layout is 3D (reads z attribute) */
16+
is3d?: boolean;
17+
}
18+
19+
/**
20+
* Creates a LayoutStrategy adapter that reads positions from graph node attributes.
21+
* This allows using `transformGraph` instead of `transformGraphWithPositions`
22+
* when positions are stored directly on the graph (e.g., from FA2 worker).
23+
*/
24+
export function createGraphPositionAdapter({
25+
graph,
26+
drags,
27+
is3d = false
28+
}: GraphPositionAdapterOptions): LayoutStrategy {
29+
return {
30+
getNodePosition(id: string): InternalGraphPosition {
31+
// Check for drag position first
32+
if (drags?.[id]?.position) {
33+
const dragPos = drags[id].position;
34+
return {
35+
x: dragPos.x,
36+
y: dragPos.y,
37+
z: dragPos.z ?? (is3d ? 0 : 1)
38+
} as InternalGraphPosition;
39+
}
40+
41+
// Read position from graph node attributes
42+
const attrs = graph.getNodeAttributes(id);
43+
return {
44+
x: (attrs as any).x ?? 0,
45+
y: (attrs as any).y ?? 0,
46+
z: is3d ? ((attrs as any).z ?? 0) : 1
47+
} as InternalGraphPosition;
48+
},
49+
50+
// No-op step since the worker already ran the layout
51+
step(): boolean {
52+
return false;
53+
}
54+
};
55+
}
56+
57+
/**
58+
* Creates a LayoutStrategy adapter from a pre-computed positions Map.
59+
* Used when positions come from a custom worker that returns a Map.
60+
*/
61+
export function createPositionMapAdapter(
62+
positions: Map<string, InternalVector3>,
63+
drags?: DragReferences,
64+
is3d = false
65+
): LayoutStrategy {
66+
return {
67+
getNodePosition(id: string): InternalGraphPosition {
68+
// Check for drag position first
69+
if (drags?.[id]?.position) {
70+
const dragPos = drags[id].position;
71+
return {
72+
x: dragPos.x,
73+
y: dragPos.y,
74+
z: dragPos.z ?? (is3d ? 0 : 1)
75+
} as InternalGraphPosition;
76+
}
77+
78+
// Read position from positions map
79+
const pos = positions.get(id);
80+
return {
81+
x: pos?.x ?? 0,
82+
y: pos?.y ?? 0,
83+
z: is3d ? (pos?.z ?? 0) : 1
84+
} as InternalGraphPosition;
85+
},
86+
87+
// No-op step since the worker already ran the layout
88+
step(): boolean {
89+
return false;
90+
}
91+
};
92+
}

src/layout/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ export * from './layoutUtils';
1212
export * from './nooverlap';
1313
export * from './recommender';
1414
export * from './types';
15+
export * from './useForceAtlas2Worker';
16+
export * from './graphPositionAdapter';

src/layout/useForceAtlas2Worker.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Hook for using graphology's built-in ForceAtlas2 web worker.
3+
* This uses the production-ready worker implementation from graphology-layout-forceatlas2.
4+
*/
5+
6+
import { useCallback, useEffect, useRef } from 'react';
7+
import type Graph from 'graphology';
8+
import random from 'graphology-layout/random.js';
9+
import FA2Layout from 'graphology-layout-forceatlas2/worker';
10+
import type { ForceAtlas2LayoutInputs } from './forceatlas2';
11+
12+
export interface FA2WorkerResult {
13+
/** Whether the layout completed successfully */
14+
success: boolean;
15+
}
16+
17+
/**
18+
* Hook that provides access to graphology's ForceAtlas2 web worker.
19+
* The FA2Layout worker runs the layout algorithm off the main thread,
20+
* updating node positions directly on the graph object.
21+
*/
22+
export function useForceAtlas2Worker() {
23+
const layoutRef = useRef<any | null>(null);
24+
const isRunningRef = useRef(false);
25+
26+
// Cleanup on unmount
27+
useEffect(() => {
28+
return () => {
29+
if (layoutRef.current) {
30+
layoutRef.current.kill();
31+
layoutRef.current = null;
32+
}
33+
};
34+
}, []);
35+
36+
/**
37+
* Run ForceAtlas2 layout using web worker.
38+
* Returns a promise that resolves when layout runs for sufficient time.
39+
*/
40+
const runLayout = useCallback(
41+
async (
42+
graph: Graph,
43+
options: Omit<ForceAtlas2LayoutInputs, 'graph' | 'drags'>
44+
): Promise<FA2WorkerResult> => {
45+
// Kill any existing layout
46+
if (layoutRef.current) {
47+
layoutRef.current.kill();
48+
layoutRef.current = null;
49+
}
50+
51+
return new Promise((resolve) => {
52+
const { iterations = 50, ...settings } = options;
53+
54+
// FA2 requires nodes to have initial x,y positions
55+
// Assign random positions if not present
56+
random.assign(graph);
57+
58+
// Create the FA2Layout worker
59+
const layout = new FA2Layout(graph, {
60+
settings
61+
});
62+
63+
layoutRef.current = layout;
64+
isRunningRef.current = true;
65+
66+
// FA2Layout worker runs continuously, we need to stop it after sufficient iterations
67+
// Each requestAnimationFrame is roughly one "tick" - we'll run for iterations * 16ms
68+
const runTimeMs = Math.max(500, iterations * 10); // At least 500ms, or 10ms per iteration
69+
70+
// Start the layout
71+
layout.start();
72+
73+
// Stop after the calculated run time
74+
setTimeout(() => {
75+
if (layoutRef.current && isRunningRef.current) {
76+
layout.stop();
77+
isRunningRef.current = false;
78+
resolve({ success: true });
79+
} else {
80+
resolve({ success: false });
81+
}
82+
}, runTimeMs);
83+
});
84+
},
85+
[]
86+
);
87+
88+
/**
89+
* Stop the current layout calculation
90+
*/
91+
const stopLayout = useCallback(() => {
92+
if (layoutRef.current) {
93+
layoutRef.current.stop();
94+
isRunningRef.current = false;
95+
}
96+
}, []);
97+
98+
/**
99+
* Kill the layout and release resources
100+
*/
101+
const killLayout = useCallback(() => {
102+
if (layoutRef.current) {
103+
layoutRef.current.kill();
104+
layoutRef.current = null;
105+
isRunningRef.current = false;
106+
}
107+
}, []);
108+
109+
/**
110+
* Check if layout is currently running
111+
*/
112+
const isRunning = useCallback(() => {
113+
return isRunningRef.current && layoutRef.current?.isRunning();
114+
}, []);
115+
116+
return {
117+
runLayout,
118+
stopLayout,
119+
killLayout,
120+
isRunning
121+
};
122+
}
123+
124+
/**
125+
* Check if ForceAtlas2 worker is supported
126+
*/
127+
export function supportsFA2Worker(): boolean {
128+
return typeof Worker !== 'undefined';
129+
}

src/typings.d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,42 @@ declare module '*.json';
22
declare module '*.css';
33
declare module '*.md';
44
declare module '*.svg';
5+
6+
// Vite inline worker imports - creates worker from blob URL
7+
declare module '*?worker&inline' {
8+
const WorkerConstructor: {
9+
new (): Worker;
10+
};
11+
export default WorkerConstructor;
12+
}
13+
14+
declare module 'graphology-layout-forceatlas2/worker' {
15+
import type Graph from 'graphology';
16+
17+
interface FA2LayoutSettings {
18+
adjustSizes?: boolean;
19+
barnesHutOptimize?: boolean;
20+
barnesHutTheta?: number;
21+
edgeWeightInfluence?: number;
22+
gravity?: number;
23+
linLogMode?: boolean;
24+
outboundAttractionDistribution?: boolean;
25+
scalingRatio?: number;
26+
slowDown?: number;
27+
strongGravityMode?: boolean;
28+
}
29+
30+
interface FA2LayoutOptions {
31+
settings?: FA2LayoutSettings;
32+
}
33+
34+
class FA2Layout {
35+
constructor(graph: Graph, options?: FA2LayoutOptions);
36+
start(): void;
37+
stop(): void;
38+
kill(): void;
39+
isRunning(): boolean;
40+
}
41+
42+
export default FA2Layout;
43+
}

0 commit comments

Comments
 (0)