@aiready/components 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/useForceSimulation.d.ts +1 -1
- package/dist/hooks/useForceSimulation.js +231 -253
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +228 -252
- package/dist/index.js.map +1 -1
- package/dist/{useForceSimulation-BNhxX4gH.d.ts → useForceSimulation-CjLhYevG.d.ts} +4 -3
- package/package.json +2 -2
- package/src/hooks/simulation-constants.ts +39 -0
- package/src/hooks/simulation-helpers.ts +42 -4
- package/src/hooks/useForceSimulation.ts +306 -421
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Hook for managing d3-force simulations
|
|
3
|
+
* Automatically handles simulation lifecycle, tick updates, and cleanup
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
7
|
+
import * as d3 from 'd3';
|
|
8
|
+
import {
|
|
9
|
+
seedRandomPositions,
|
|
10
|
+
seedCircularPositions,
|
|
11
|
+
safelyStopSimulation,
|
|
12
|
+
} from './simulation-helpers';
|
|
13
|
+
import {
|
|
14
|
+
SIMULATION_DEFAULTS,
|
|
15
|
+
FORCE_NAMES,
|
|
16
|
+
EVENT_NAMES,
|
|
17
|
+
} from './simulation-constants';
|
|
3
18
|
import type {
|
|
4
19
|
SimulationNode,
|
|
5
20
|
SimulationLink,
|
|
@@ -7,99 +22,43 @@ import type {
|
|
|
7
22
|
UseForceSimulationReturn,
|
|
8
23
|
} from './simulation-types';
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Enhanced return type for the force simulation hook
|
|
27
|
+
*/
|
|
28
|
+
export interface UseForceSimulationReturnExt extends UseForceSimulationReturn {
|
|
29
|
+
/**
|
|
30
|
+
* Enable or disable simulation forces (charge and link forces)
|
|
31
|
+
*/
|
|
32
|
+
setForcesEnabled: (enabled: boolean) => void;
|
|
33
|
+
}
|
|
12
34
|
|
|
13
35
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* @param initialNodes - Initial nodes for the simulation
|
|
18
|
-
* @param initialLinks - Initial links for the simulation
|
|
19
|
-
* @param options - Configuration options for the force simulation
|
|
20
|
-
* @returns Simulation state and control functions
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* ```tsx
|
|
24
|
-
* function NetworkGraph() {
|
|
25
|
-
* const nodes = [
|
|
26
|
-
* { id: 'node1', name: 'Node 1' },
|
|
27
|
-
* { id: 'node2', name: 'Node 2' },
|
|
28
|
-
* { id: 'node3', name: 'Node 3' },
|
|
29
|
-
* ];
|
|
30
|
-
*
|
|
31
|
-
* const links = [
|
|
32
|
-
* { source: 'node1', target: 'node2' },
|
|
33
|
-
* { source: 'node2', target: 'node3' },
|
|
34
|
-
* ];
|
|
35
|
-
*
|
|
36
|
-
* const { nodes: simulatedNodes, links: simulatedLinks, restart } = useForceSimulation(
|
|
37
|
-
* nodes,
|
|
38
|
-
* links,
|
|
39
|
-
* {
|
|
40
|
-
* width: 800,
|
|
41
|
-
* height: 600,
|
|
42
|
-
* chargeStrength: -500,
|
|
43
|
-
* linkDistance: 150,
|
|
44
|
-
* }
|
|
45
|
-
* );
|
|
46
|
-
*
|
|
47
|
-
* return (
|
|
48
|
-
* <svg width={800} height={600}>
|
|
49
|
-
* {simulatedLinks.map((link, i) => (
|
|
50
|
-
* <line
|
|
51
|
-
* key={i}
|
|
52
|
-
* x1={(link.source as SimulationNode).x}
|
|
53
|
-
* y1={(link.source as SimulationNode).y}
|
|
54
|
-
* x2={(link.target as SimulationNode).x}
|
|
55
|
-
* y2={(link.target as SimulationNode).y}
|
|
56
|
-
* stroke="#999"
|
|
57
|
-
* />
|
|
58
|
-
* ))}
|
|
59
|
-
* {simulatedNodes.map((node) => (
|
|
60
|
-
* <circle
|
|
61
|
-
* key={node.id}
|
|
62
|
-
* cx={node.x}
|
|
63
|
-
* cy={node.y}
|
|
64
|
-
* r={10}
|
|
65
|
-
* fill="#69b3a2"
|
|
66
|
-
* />
|
|
67
|
-
* ))}
|
|
68
|
-
* </svg>
|
|
69
|
-
* );
|
|
70
|
-
* }
|
|
71
|
-
* ```
|
|
36
|
+
* useForceSimulation: robust d3-force management with React
|
|
37
|
+
* @lastUpdated 2026-03-27
|
|
72
38
|
*/
|
|
73
39
|
export function useForceSimulation(
|
|
74
40
|
initialNodes: SimulationNode[],
|
|
75
41
|
initialLinks: SimulationLink[],
|
|
76
42
|
options: ForceSimulationOptions
|
|
77
|
-
):
|
|
78
|
-
/**
|
|
79
|
-
* Enable or disable the simulation forces (charge and link forces).
|
|
80
|
-
* When disabled, nodes can still be dragged but won't be affected by forces.
|
|
81
|
-
* @param enabled - When true, simulation forces are active; when false, forces are disabled
|
|
82
|
-
*/
|
|
43
|
+
): UseForceSimulationReturnExt {
|
|
83
44
|
const {
|
|
84
|
-
chargeStrength =
|
|
85
|
-
linkDistance =
|
|
86
|
-
linkStrength =
|
|
87
|
-
collisionStrength =
|
|
88
|
-
collisionRadius =
|
|
89
|
-
centerStrength =
|
|
45
|
+
chargeStrength = SIMULATION_DEFAULTS.CHARGE_STRENGTH,
|
|
46
|
+
linkDistance = SIMULATION_DEFAULTS.LINK_DISTANCE,
|
|
47
|
+
linkStrength = SIMULATION_DEFAULTS.LINK_STRENGTH,
|
|
48
|
+
collisionStrength = SIMULATION_DEFAULTS.COLLISION_STRENGTH,
|
|
49
|
+
collisionRadius = SIMULATION_DEFAULTS.COLLISION_RADIUS,
|
|
50
|
+
centerStrength = SIMULATION_DEFAULTS.CENTER_STRENGTH,
|
|
90
51
|
width,
|
|
91
52
|
height,
|
|
92
|
-
alphaDecay =
|
|
93
|
-
velocityDecay =
|
|
94
|
-
alphaTarget =
|
|
95
|
-
warmAlpha =
|
|
96
|
-
alphaMin =
|
|
53
|
+
alphaDecay = SIMULATION_DEFAULTS.ALPHA_DECAY,
|
|
54
|
+
velocityDecay = SIMULATION_DEFAULTS.VELOCITY_DECAY,
|
|
55
|
+
alphaTarget = SIMULATION_DEFAULTS.ALPHA_TARGET,
|
|
56
|
+
warmAlpha = SIMULATION_DEFAULTS.WARM_ALPHA,
|
|
57
|
+
alphaMin = SIMULATION_DEFAULTS.ALPHA_MIN,
|
|
97
58
|
onTick,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
tickThrottleMs = 33,
|
|
102
|
-
maxSimulationTimeMs = 3000,
|
|
59
|
+
stabilizeOnStop = SIMULATION_DEFAULTS.STABILIZE_ON_STOP,
|
|
60
|
+
tickThrottleMs = SIMULATION_DEFAULTS.TICK_THROTTLE_MS,
|
|
61
|
+
maxSimulationTimeMs = SIMULATION_DEFAULTS.MAX_SIMULATION_TIME_MS,
|
|
103
62
|
} = options;
|
|
104
63
|
|
|
105
64
|
const [nodes, setNodes] = useState<SimulationNode[]>(initialNodes);
|
|
@@ -112,12 +71,15 @@ export function useForceSimulation(
|
|
|
112
71
|
SimulationLink
|
|
113
72
|
> | null>(null);
|
|
114
73
|
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
74
|
+
const forcesEnabledRef = useRef(true);
|
|
75
|
+
const originalForcesRef = useRef({
|
|
76
|
+
charge: chargeStrength,
|
|
77
|
+
link: linkStrength,
|
|
78
|
+
});
|
|
115
79
|
|
|
116
|
-
//
|
|
117
|
-
// when the actual identity/content of inputs change (not when parent passes
|
|
118
|
-
// new array references on each render).
|
|
80
|
+
// Unique keys to detect when to rebuild the simulation
|
|
119
81
|
const nodesKey = initialNodes.map((n) => n.id).join('|');
|
|
120
|
-
const linksKey =
|
|
82
|
+
const linksKey = initialLinks
|
|
121
83
|
.map((l) => {
|
|
122
84
|
const sourceId =
|
|
123
85
|
typeof l.source === 'string'
|
|
@@ -127,254 +89,42 @@ export function useForceSimulation(
|
|
|
127
89
|
typeof l.target === 'string'
|
|
128
90
|
? l.target
|
|
129
91
|
: (l.target as SimulationNode)?.id;
|
|
130
|
-
const linkType = (l as
|
|
92
|
+
const linkType = (l as any).type || '';
|
|
131
93
|
return `${sourceId}->${targetId}:${linkType}`;
|
|
132
94
|
})
|
|
133
95
|
.join('|');
|
|
134
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Internal effect to manage simulation lifecycle
|
|
99
|
+
*/
|
|
135
100
|
useEffect(() => {
|
|
136
|
-
// Create a copy of nodes and links to avoid mutating the original data
|
|
137
101
|
const nodesCopy = initialNodes.map((node) => ({ ...node }));
|
|
138
102
|
const linksCopy = initialLinks.map((link) => ({ ...link }));
|
|
139
103
|
|
|
140
|
-
// ALWAYS seed initial positions to ensure nodes don't stack at origin
|
|
141
|
-
// This is critical for force-directed graphs to work properly
|
|
142
104
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const radius = Math.min(width, height) * 0.45;
|
|
150
|
-
n.x = width / 2 + radius * Math.cos(angle);
|
|
151
|
-
n.y = height / 2 + radius * Math.sin(angle);
|
|
152
|
-
// Add very small random velocity to avoid large initial motion
|
|
153
|
-
n.vx = (Math.random() - 0.5) * 2;
|
|
154
|
-
n.vy = (Math.random() - 0.5) * 2;
|
|
155
|
-
});
|
|
156
|
-
} catch (e) {
|
|
157
|
-
console.warn('Failed to seed node positions, falling back to random:', e);
|
|
158
|
-
// If error, fall back to random positions
|
|
105
|
+
seedCircularPositions(nodesCopy, width, height);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.warn(
|
|
108
|
+
'AIReady: Position seeding failed, using random fallback:',
|
|
109
|
+
error
|
|
110
|
+
);
|
|
159
111
|
seedRandomPositions(nodesCopy, width, height);
|
|
160
112
|
}
|
|
161
113
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// Configure link force separately to avoid using generic type args on d3 helpers
|
|
168
|
-
try {
|
|
169
|
-
const linkForce = d3.forceLink(
|
|
170
|
-
linksCopy as d3.SimulationLinkDatum<SimulationNode>[]
|
|
171
|
-
) as d3.ForceLink<SimulationNode, d3.SimulationLinkDatum<SimulationNode>>;
|
|
172
|
-
linkForce
|
|
173
|
-
.id((d: SimulationNode) => d.id)
|
|
174
|
-
.distance((d: d3.SimulationLinkDatum<SimulationNode>) => {
|
|
175
|
-
const link = d as SimulationLink & { distance?: number };
|
|
176
|
-
return link && link.distance != null ? link.distance : linkDistance;
|
|
177
|
-
})
|
|
178
|
-
.strength(linkStrength);
|
|
179
|
-
simulation.force(
|
|
180
|
-
'link',
|
|
181
|
-
linkForce as d3.Force<
|
|
182
|
-
SimulationNode,
|
|
183
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
184
|
-
>
|
|
185
|
-
);
|
|
186
|
-
} catch (e) {
|
|
187
|
-
console.warn('Failed to configure link force, using fallback:', e);
|
|
188
|
-
// fallback: attach a plain link force
|
|
189
|
-
try {
|
|
190
|
-
simulation.force(
|
|
191
|
-
'link',
|
|
192
|
-
d3.forceLink(
|
|
193
|
-
linksCopy as d3.SimulationLinkDatum<SimulationNode>[]
|
|
194
|
-
) as d3.Force<SimulationNode, d3.SimulationLinkDatum<SimulationNode>>
|
|
195
|
-
);
|
|
196
|
-
} catch (fallbackError) {
|
|
197
|
-
console.warn('Fallback link force also failed:', fallbackError);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
try {
|
|
201
|
-
simulation.force(
|
|
202
|
-
'charge',
|
|
203
|
-
d3.forceManyBody().strength(chargeStrength) as d3.Force<
|
|
204
|
-
SimulationNode,
|
|
205
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
206
|
-
>
|
|
207
|
-
);
|
|
208
|
-
simulation.force(
|
|
209
|
-
'center',
|
|
210
|
-
d3
|
|
211
|
-
.forceCenter(width / 2, height / 2)
|
|
212
|
-
.strength(centerStrength) as d3.Force<
|
|
213
|
-
SimulationNode,
|
|
214
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
215
|
-
>
|
|
216
|
-
);
|
|
217
|
-
const collide = d3
|
|
218
|
-
.forceCollide()
|
|
219
|
-
.radius((d: d3.SimulationNodeDatum) => {
|
|
220
|
-
const node = d as SimulationNode;
|
|
221
|
-
const nodeSize = node && node.size ? (node.size as number) : 10;
|
|
222
|
-
return nodeSize + collisionRadius;
|
|
223
|
-
})
|
|
224
|
-
.strength(collisionStrength) as d3.Force<
|
|
225
|
-
SimulationNode,
|
|
226
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
227
|
-
>;
|
|
228
|
-
simulation.force('collision', collide);
|
|
229
|
-
simulation.force(
|
|
230
|
-
'x',
|
|
231
|
-
d3
|
|
232
|
-
.forceX(width / 2)
|
|
233
|
-
.strength(Math.max(0.02, centerStrength * 0.5)) as d3.Force<
|
|
234
|
-
SimulationNode,
|
|
235
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
236
|
-
>
|
|
237
|
-
);
|
|
238
|
-
simulation.force(
|
|
239
|
-
'y',
|
|
240
|
-
d3
|
|
241
|
-
.forceY(height / 2)
|
|
242
|
-
.strength(Math.max(0.02, centerStrength * 0.5)) as d3.Force<
|
|
243
|
-
SimulationNode,
|
|
244
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
245
|
-
>
|
|
246
|
-
);
|
|
247
|
-
simulation.alphaDecay(alphaDecay);
|
|
248
|
-
simulation.velocityDecay(velocityDecay);
|
|
249
|
-
simulation.alphaMin(alphaMin);
|
|
250
|
-
try {
|
|
251
|
-
simulation.alphaTarget(alphaTarget);
|
|
252
|
-
} catch (e) {
|
|
253
|
-
console.warn('Failed to set alpha target:', e);
|
|
254
|
-
}
|
|
255
|
-
try {
|
|
256
|
-
simulation.alpha(warmAlpha);
|
|
257
|
-
} catch (e) {
|
|
258
|
-
console.warn('Failed to set initial alpha:', e);
|
|
259
|
-
}
|
|
260
|
-
} catch (e) {
|
|
261
|
-
console.warn('Failed to configure simulation forces:', e);
|
|
262
|
-
// ignore force configuration errors
|
|
263
|
-
}
|
|
114
|
+
const simulation = d3.forceSimulation<SimulationNode, SimulationLink>(
|
|
115
|
+
nodesCopy
|
|
116
|
+
);
|
|
117
|
+
applySimulationForces(simulation, linksCopy);
|
|
118
|
+
configureSimulationParameters(simulation);
|
|
264
119
|
|
|
265
120
|
simulationRef.current = simulation;
|
|
266
121
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
globalThis.clearTimeout(stopTimeoutRef.current);
|
|
271
|
-
} catch (e) {
|
|
272
|
-
console.warn('Failed to clear simulation timeout:', e);
|
|
273
|
-
}
|
|
274
|
-
stopTimeoutRef.current = null;
|
|
275
|
-
}
|
|
276
|
-
if (maxSimulationTimeMs && maxSimulationTimeMs > 0) {
|
|
277
|
-
stopTimeoutRef.current = globalThis.setTimeout(() => {
|
|
278
|
-
try {
|
|
279
|
-
if (stabilizeOnStop) {
|
|
280
|
-
stabilizeNodes(nodesCopy);
|
|
281
|
-
}
|
|
282
|
-
simulation.alpha(0);
|
|
283
|
-
simulation.stop();
|
|
284
|
-
} catch (e) {
|
|
285
|
-
console.warn('Failed to stop simulation:', e);
|
|
286
|
-
}
|
|
287
|
-
setIsRunning(false);
|
|
288
|
-
setNodes([...nodesCopy]);
|
|
289
|
-
setLinks([...linksCopy]);
|
|
290
|
-
}, maxSimulationTimeMs);
|
|
291
|
-
}
|
|
122
|
+
const rafState = { rafId: null as number | null, lastUpdate: 0 };
|
|
123
|
+
setupTickHandler(simulation, nodesCopy, linksCopy, rafState);
|
|
292
124
|
|
|
293
|
-
|
|
294
|
-
// excessive React re-renders which can cause visual flicker.
|
|
295
|
-
let rafId: number | null = null;
|
|
296
|
-
let lastUpdate = 0;
|
|
297
|
-
const tickHandler = () => {
|
|
298
|
-
try {
|
|
299
|
-
if (typeof onTick === 'function')
|
|
300
|
-
onTick(nodesCopy, linksCopy, simulation);
|
|
301
|
-
} catch (e) {
|
|
302
|
-
console.warn('Tick callback error:', e);
|
|
303
|
-
}
|
|
125
|
+
setupStopTimer(simulation, nodesCopy, linksCopy);
|
|
304
126
|
|
|
305
|
-
|
|
306
|
-
// ensure nodes don't drift indefinitely (acts as a hard-stop safeguard).
|
|
307
|
-
try {
|
|
308
|
-
if (simulation.alpha() <= alphaMin) {
|
|
309
|
-
try {
|
|
310
|
-
if (stabilizeOnStop) {
|
|
311
|
-
stabilizeNodes(nodesCopy);
|
|
312
|
-
}
|
|
313
|
-
simulation.stop();
|
|
314
|
-
} catch (e) {
|
|
315
|
-
console.warn('Failed to stop simulation:', e);
|
|
316
|
-
}
|
|
317
|
-
setAlpha(simulation.alpha());
|
|
318
|
-
setIsRunning(false);
|
|
319
|
-
setNodes([...nodesCopy]);
|
|
320
|
-
setLinks([...linksCopy]);
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
} catch (e) {
|
|
324
|
-
console.warn('Error checking simulation alpha:', e);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const now = Date.now();
|
|
328
|
-
const shouldUpdate = now - lastUpdate >= tickThrottleMs;
|
|
329
|
-
if (rafId == null && shouldUpdate) {
|
|
330
|
-
rafId = (
|
|
331
|
-
globalThis.requestAnimationFrame ||
|
|
332
|
-
((cb: FrameRequestCallback) => setTimeout(cb, 16))
|
|
333
|
-
)(() => {
|
|
334
|
-
rafId = null;
|
|
335
|
-
lastUpdate = Date.now();
|
|
336
|
-
setNodes([...nodesCopy]);
|
|
337
|
-
setLinks([...linksCopy]);
|
|
338
|
-
setAlpha(simulation.alpha());
|
|
339
|
-
setIsRunning(simulation.alpha() > simulation.alphaMin());
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
simulation.on('tick', tickHandler);
|
|
345
|
-
|
|
346
|
-
simulation.on('end', () => {
|
|
347
|
-
setIsRunning(false);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// Cleanup on unmount
|
|
351
|
-
return () => {
|
|
352
|
-
try {
|
|
353
|
-
simulation.on('tick', null);
|
|
354
|
-
} catch (e) {
|
|
355
|
-
console.warn('Failed to clear simulation tick handler:', e);
|
|
356
|
-
}
|
|
357
|
-
if (stopTimeoutRef.current != null) {
|
|
358
|
-
try {
|
|
359
|
-
globalThis.clearTimeout(stopTimeoutRef.current);
|
|
360
|
-
} catch (e) {
|
|
361
|
-
console.warn('Failed to clear timeout on cleanup:', e);
|
|
362
|
-
}
|
|
363
|
-
stopTimeoutRef.current = null;
|
|
364
|
-
}
|
|
365
|
-
if (rafId != null) {
|
|
366
|
-
try {
|
|
367
|
-
(
|
|
368
|
-
globalThis.cancelAnimationFrame ||
|
|
369
|
-
((id: number) => clearTimeout(id))
|
|
370
|
-
)(rafId);
|
|
371
|
-
} catch (e) {
|
|
372
|
-
console.warn('Failed to cancel animation frame:', e);
|
|
373
|
-
}
|
|
374
|
-
rafId = null;
|
|
375
|
-
}
|
|
376
|
-
simulation.stop();
|
|
377
|
-
};
|
|
127
|
+
return () => cleanupSimulation(simulation, rafState);
|
|
378
128
|
}, [
|
|
379
129
|
nodesKey,
|
|
380
130
|
linksKey,
|
|
@@ -395,86 +145,241 @@ export function useForceSimulation(
|
|
|
395
145
|
maxSimulationTimeMs,
|
|
396
146
|
]);
|
|
397
147
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Applies d3 forces to the simulation instance
|
|
150
|
+
*/
|
|
151
|
+
const applySimulationForces = (
|
|
152
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
153
|
+
linksCopy: SimulationLink[]
|
|
154
|
+
) => {
|
|
155
|
+
try {
|
|
156
|
+
const linkForce = d3
|
|
157
|
+
.forceLink<SimulationNode, SimulationLink>(linksCopy)
|
|
158
|
+
.id((d) => d.id)
|
|
159
|
+
.distance((d) => (d as any).distance ?? linkDistance)
|
|
160
|
+
.strength(linkStrength);
|
|
161
|
+
|
|
162
|
+
simulation
|
|
163
|
+
.force(FORCE_NAMES.LINK, linkForce)
|
|
164
|
+
.force(FORCE_NAMES.CHARGE, d3.forceManyBody().strength(chargeStrength))
|
|
165
|
+
.force(
|
|
166
|
+
FORCE_NAMES.CENTER,
|
|
167
|
+
d3.forceCenter(width / 2, height / 2).strength(centerStrength)
|
|
168
|
+
)
|
|
169
|
+
.force(
|
|
170
|
+
FORCE_NAMES.COLLISION,
|
|
171
|
+
d3
|
|
172
|
+
.forceCollide<SimulationNode>()
|
|
173
|
+
.radius((d) => (d.size ?? 10) + collisionRadius)
|
|
174
|
+
.strength(collisionStrength)
|
|
175
|
+
)
|
|
176
|
+
.force(
|
|
177
|
+
FORCE_NAMES.X,
|
|
178
|
+
d3.forceX(width / 2).strength(Math.max(0.02, centerStrength * 0.5))
|
|
179
|
+
)
|
|
180
|
+
.force(
|
|
181
|
+
FORCE_NAMES.Y,
|
|
182
|
+
d3.forceY(height / 2).strength(Math.max(0.02, centerStrength * 0.5))
|
|
183
|
+
);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('AIReady: Failed to configure simulation forces:', error);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Configures simulation decay and heat parameters
|
|
191
|
+
*/
|
|
192
|
+
const configureSimulationParameters = (
|
|
193
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>
|
|
194
|
+
) => {
|
|
195
|
+
simulation
|
|
196
|
+
.alphaDecay(alphaDecay)
|
|
197
|
+
.velocityDecay(velocityDecay)
|
|
198
|
+
.alphaMin(alphaMin)
|
|
199
|
+
.alphaTarget(alphaTarget)
|
|
200
|
+
.alpha(warmAlpha);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sets up a timer to force-stop the simulation after maxSimulationTimeMs
|
|
205
|
+
*/
|
|
206
|
+
const setupStopTimer = (
|
|
207
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
208
|
+
nodesCopy: SimulationNode[],
|
|
209
|
+
linksCopy: SimulationLink[]
|
|
210
|
+
) => {
|
|
211
|
+
if (stopTimeoutRef.current) {
|
|
212
|
+
clearTimeout(stopTimeoutRef.current);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (maxSimulationTimeMs > 0) {
|
|
216
|
+
stopTimeoutRef.current = setTimeout(() => {
|
|
217
|
+
safelyStopSimulation(simulation, nodesCopy, {
|
|
218
|
+
stabilize: stabilizeOnStop,
|
|
219
|
+
});
|
|
220
|
+
updateStateAfterStop(nodesCopy, linksCopy, 0);
|
|
221
|
+
}, maxSimulationTimeMs);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Updates state variables after simulation stops
|
|
227
|
+
*/
|
|
228
|
+
const updateStateAfterStop = (
|
|
229
|
+
nodesCopy: SimulationNode[],
|
|
230
|
+
linksCopy: SimulationLink[],
|
|
231
|
+
currentAlpha: number
|
|
232
|
+
) => {
|
|
233
|
+
setIsRunning(false);
|
|
234
|
+
setAlpha(currentAlpha);
|
|
235
|
+
setNodes([...nodesCopy]);
|
|
236
|
+
setLinks([...linksCopy]);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Manages simulation ticks and React state sync
|
|
241
|
+
*/
|
|
242
|
+
const setupTickHandler = (
|
|
243
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
244
|
+
nodesCopy: SimulationNode[],
|
|
245
|
+
linksCopy: SimulationLink[],
|
|
246
|
+
rafState: { rafId: number | null; lastUpdate: number }
|
|
247
|
+
) => {
|
|
248
|
+
const handleTick = () => {
|
|
249
|
+
if (onTick) {
|
|
410
250
|
try {
|
|
411
|
-
|
|
412
|
-
} catch (
|
|
413
|
-
console.warn('
|
|
251
|
+
onTick(nodesCopy, linksCopy, simulation);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.warn('AIReady: Simulation onTick callback failed:', error);
|
|
414
254
|
}
|
|
415
|
-
stopTimeoutRef.current = null;
|
|
416
255
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
setIsRunning(false);
|
|
426
|
-
}, maxSimulationTimeMs);
|
|
256
|
+
|
|
257
|
+
const currentAlpha = simulation.alpha();
|
|
258
|
+
if (currentAlpha <= alphaMin) {
|
|
259
|
+
safelyStopSimulation(simulation, nodesCopy, {
|
|
260
|
+
stabilize: stabilizeOnStop,
|
|
261
|
+
});
|
|
262
|
+
updateStateAfterStop(nodesCopy, linksCopy, currentAlpha);
|
|
263
|
+
return;
|
|
427
264
|
}
|
|
428
|
-
|
|
265
|
+
|
|
266
|
+
syncStateOnTick(nodesCopy, linksCopy, currentAlpha, rafState);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
simulation.on(EVENT_NAMES.TICK, handleTick);
|
|
270
|
+
simulation.on(EVENT_NAMES.END, () => setIsRunning(false));
|
|
429
271
|
};
|
|
430
272
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
273
|
+
/**
|
|
274
|
+
* Syncs simulation results to React state using requestAnimationFrame
|
|
275
|
+
*/
|
|
276
|
+
const syncStateOnTick = (
|
|
277
|
+
nodesCopy: SimulationNode[],
|
|
278
|
+
linksCopy: SimulationLink[],
|
|
279
|
+
currentAlpha: number,
|
|
280
|
+
rafState: { rafId: number | null; lastUpdate: number }
|
|
281
|
+
) => {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
if (
|
|
284
|
+
rafState.rafId === null &&
|
|
285
|
+
now - rafState.lastUpdate >= tickThrottleMs
|
|
286
|
+
) {
|
|
287
|
+
rafState.rafId = requestAnimationFrame(() => {
|
|
288
|
+
rafState.rafId = null;
|
|
289
|
+
rafState.lastUpdate = Date.now();
|
|
290
|
+
setNodes([...nodesCopy]);
|
|
291
|
+
setLinks([...linksCopy]);
|
|
292
|
+
setAlpha(currentAlpha);
|
|
293
|
+
setIsRunning(currentAlpha > alphaMin);
|
|
294
|
+
});
|
|
435
295
|
}
|
|
436
296
|
};
|
|
437
297
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Cleanup routine for simulation unmount or rebuild
|
|
300
|
+
*/
|
|
301
|
+
const cleanupSimulation = (
|
|
302
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
303
|
+
rafState: { rafId: number | null }
|
|
304
|
+
) => {
|
|
305
|
+
simulation.on(EVENT_NAMES.TICK, null);
|
|
306
|
+
if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current);
|
|
307
|
+
if (rafState.rafId !== null) cancelAnimationFrame(rafState.rafId);
|
|
308
|
+
simulation.stop();
|
|
309
|
+
};
|
|
444
310
|
|
|
445
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Restart the simulation manually
|
|
313
|
+
*/
|
|
314
|
+
const restartSimulation = useCallback(() => {
|
|
446
315
|
const sim = simulationRef.current;
|
|
447
316
|
if (!sim) return;
|
|
448
|
-
// avoid repeated updates
|
|
449
|
-
if (forcesEnabledRef.current === enabled) return;
|
|
450
|
-
forcesEnabledRef.current = enabled;
|
|
451
317
|
|
|
452
318
|
try {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
319
|
+
sim.alphaTarget(warmAlpha).restart();
|
|
320
|
+
setIsRunning(true);
|
|
321
|
+
if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current);
|
|
322
|
+
if (maxSimulationTimeMs > 0) {
|
|
323
|
+
stopTimeoutRef.current = setTimeout(() => {
|
|
324
|
+
sim.alpha(0);
|
|
325
|
+
sim.stop();
|
|
326
|
+
setIsRunning(false);
|
|
327
|
+
}, maxSimulationTimeMs);
|
|
459
328
|
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.warn('AIReady: Failed to restart simulation:', error);
|
|
331
|
+
}
|
|
332
|
+
}, [warmAlpha, maxSimulationTimeMs]);
|
|
460
333
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
} catch (e) {
|
|
469
|
-
console.warn('Failed to toggle simulation forces:', e);
|
|
334
|
+
/**
|
|
335
|
+
* Stop the simulation manually
|
|
336
|
+
*/
|
|
337
|
+
const stopSimulation = useCallback(() => {
|
|
338
|
+
if (simulationRef.current) {
|
|
339
|
+
simulationRef.current.stop();
|
|
340
|
+
setIsRunning(false);
|
|
470
341
|
}
|
|
471
|
-
};
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Enable or disable simulation forces
|
|
346
|
+
*/
|
|
347
|
+
const setForcesEnabled = useCallback(
|
|
348
|
+
(enabled: boolean) => {
|
|
349
|
+
const sim = simulationRef.current;
|
|
350
|
+
if (!sim || forcesEnabledRef.current === enabled) return;
|
|
351
|
+
|
|
352
|
+
forcesEnabledRef.current = enabled;
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const charge = sim.force(
|
|
356
|
+
FORCE_NAMES.CHARGE
|
|
357
|
+
) as d3.ForceManyBody<SimulationNode> | null;
|
|
358
|
+
if (charge) {
|
|
359
|
+
charge.strength(enabled ? originalForcesRef.current.charge : 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const link = sim.force(FORCE_NAMES.LINK) as d3.ForceLink<
|
|
363
|
+
SimulationNode,
|
|
364
|
+
SimulationLink
|
|
365
|
+
> | null;
|
|
366
|
+
if (link) {
|
|
367
|
+
link.strength(enabled ? originalForcesRef.current.link : 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
sim.alpha(warmAlpha).restart();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.warn('AIReady: Failed to toggle simulation forces:', error);
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
[warmAlpha]
|
|
376
|
+
);
|
|
472
377
|
|
|
473
378
|
return {
|
|
474
379
|
nodes,
|
|
475
380
|
links,
|
|
476
|
-
restart,
|
|
477
|
-
stop,
|
|
381
|
+
restart: restartSimulation,
|
|
382
|
+
stop: stopSimulation,
|
|
478
383
|
isRunning,
|
|
479
384
|
alpha,
|
|
480
385
|
setForcesEnabled,
|
|
@@ -483,58 +388,38 @@ export function useForceSimulation(
|
|
|
483
388
|
|
|
484
389
|
/**
|
|
485
390
|
* Hook for creating a draggable force simulation
|
|
486
|
-
* Provides drag handlers that can be attached to node elements
|
|
487
|
-
*
|
|
488
|
-
* @param simulation - The d3 force simulation instance
|
|
489
|
-
* @returns Drag behavior that can be applied to nodes
|
|
490
|
-
*
|
|
491
|
-
* @example
|
|
492
|
-
* ```tsx
|
|
493
|
-
* function DraggableNetworkGraph() {
|
|
494
|
-
* const simulation = useRef<d3.Simulation<SimulationNode, SimulationLink>>();
|
|
495
|
-
* const drag = useDrag(simulation.current);
|
|
496
|
-
*
|
|
497
|
-
* return (
|
|
498
|
-
* <svg>
|
|
499
|
-
* {nodes.map((node) => (
|
|
500
|
-
* <circle
|
|
501
|
-
* key={node.id}
|
|
502
|
-
* {...drag}
|
|
503
|
-
* cx={node.x}
|
|
504
|
-
* cy={node.y}
|
|
505
|
-
* r={10}
|
|
506
|
-
* />
|
|
507
|
-
* ))}
|
|
508
|
-
* </svg>
|
|
509
|
-
* );
|
|
510
|
-
* }
|
|
511
|
-
* ```
|
|
512
391
|
*/
|
|
513
392
|
export function useDrag(
|
|
514
393
|
simulation: d3.Simulation<SimulationNode, any> | null | undefined
|
|
515
394
|
) {
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
395
|
+
const handleDragStart = useCallback(
|
|
396
|
+
(event: any, node: SimulationNode) => {
|
|
397
|
+
if (!simulation) return;
|
|
398
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
399
|
+
node.fx = node.x;
|
|
400
|
+
node.fy = node.y;
|
|
401
|
+
},
|
|
402
|
+
[simulation]
|
|
403
|
+
);
|
|
522
404
|
|
|
523
|
-
const
|
|
405
|
+
const handleDragged = useCallback((event: any, node: SimulationNode) => {
|
|
524
406
|
node.fx = event.x;
|
|
525
407
|
node.fy = event.y;
|
|
526
|
-
};
|
|
408
|
+
}, []);
|
|
527
409
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
410
|
+
const handleDragEnd = useCallback(
|
|
411
|
+
(event: any, node: SimulationNode) => {
|
|
412
|
+
if (!simulation) return;
|
|
413
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
414
|
+
node.fx = null;
|
|
415
|
+
node.fy = null;
|
|
416
|
+
},
|
|
417
|
+
[simulation]
|
|
418
|
+
);
|
|
534
419
|
|
|
535
420
|
return {
|
|
536
|
-
onDragStart:
|
|
537
|
-
onDrag:
|
|
538
|
-
onDragEnd:
|
|
421
|
+
onDragStart: handleDragStart,
|
|
422
|
+
onDrag: handleDragged,
|
|
423
|
+
onDragEnd: handleDragEnd,
|
|
539
424
|
};
|
|
540
425
|
}
|