@aiready/components 0.14.1 → 0.14.3
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/components/button.d.ts +1 -1
- package/dist/hooks/useForceSimulation.d.ts +1 -1
- package/dist/hooks/useForceSimulation.js +204 -249
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +201 -248
- 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 +266 -430
|
@@ -1,5 +1,16 @@
|
|
|
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 { SIMULATION_DEFAULTS, FORCE_NAMES, EVENT_NAMES } from './simulation-constants';
|
|
3
14
|
import type {
|
|
4
15
|
SimulationNode,
|
|
5
16
|
SimulationLink,
|
|
@@ -7,99 +18,43 @@ import type {
|
|
|
7
18
|
UseForceSimulationReturn,
|
|
8
19
|
} from './simulation-types';
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Enhanced return type for the force simulation hook
|
|
23
|
+
*/
|
|
24
|
+
export interface UseForceSimulationReturnExt extends UseForceSimulationReturn {
|
|
25
|
+
/**
|
|
26
|
+
* Enable or disable simulation forces (charge and link forces)
|
|
27
|
+
*/
|
|
28
|
+
setForcesEnabled: (enabled: boolean) => void;
|
|
29
|
+
}
|
|
12
30
|
|
|
13
31
|
/**
|
|
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
|
-
* ```
|
|
32
|
+
* useForceSimulation: robust d3-force management with React
|
|
33
|
+
* @lastUpdated 2026-03-27
|
|
72
34
|
*/
|
|
73
35
|
export function useForceSimulation(
|
|
74
36
|
initialNodes: SimulationNode[],
|
|
75
37
|
initialLinks: SimulationLink[],
|
|
76
38
|
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
|
-
*/
|
|
39
|
+
): UseForceSimulationReturnExt {
|
|
83
40
|
const {
|
|
84
|
-
chargeStrength =
|
|
85
|
-
linkDistance =
|
|
86
|
-
linkStrength =
|
|
87
|
-
collisionStrength =
|
|
88
|
-
collisionRadius =
|
|
89
|
-
centerStrength =
|
|
41
|
+
chargeStrength = SIMULATION_DEFAULTS.CHARGE_STRENGTH,
|
|
42
|
+
linkDistance = SIMULATION_DEFAULTS.LINK_DISTANCE,
|
|
43
|
+
linkStrength = SIMULATION_DEFAULTS.LINK_STRENGTH,
|
|
44
|
+
collisionStrength = SIMULATION_DEFAULTS.COLLISION_STRENGTH,
|
|
45
|
+
collisionRadius = SIMULATION_DEFAULTS.COLLISION_RADIUS,
|
|
46
|
+
centerStrength = SIMULATION_DEFAULTS.CENTER_STRENGTH,
|
|
90
47
|
width,
|
|
91
48
|
height,
|
|
92
|
-
alphaDecay =
|
|
93
|
-
velocityDecay =
|
|
94
|
-
alphaTarget =
|
|
95
|
-
warmAlpha =
|
|
96
|
-
alphaMin =
|
|
49
|
+
alphaDecay = SIMULATION_DEFAULTS.ALPHA_DECAY,
|
|
50
|
+
velocityDecay = SIMULATION_DEFAULTS.VELOCITY_DECAY,
|
|
51
|
+
alphaTarget = SIMULATION_DEFAULTS.ALPHA_TARGET,
|
|
52
|
+
warmAlpha = SIMULATION_DEFAULTS.WARM_ALPHA,
|
|
53
|
+
alphaMin = SIMULATION_DEFAULTS.ALPHA_MIN,
|
|
97
54
|
onTick,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
tickThrottleMs = 33,
|
|
102
|
-
maxSimulationTimeMs = 3000,
|
|
55
|
+
stabilizeOnStop = SIMULATION_DEFAULTS.STABILIZE_ON_STOP,
|
|
56
|
+
tickThrottleMs = SIMULATION_DEFAULTS.TICK_THROTTLE_MS,
|
|
57
|
+
maxSimulationTimeMs = SIMULATION_DEFAULTS.MAX_SIMULATION_TIME_MS,
|
|
103
58
|
} = options;
|
|
104
59
|
|
|
105
60
|
const [nodes, setNodes] = useState<SimulationNode[]>(initialNodes);
|
|
@@ -107,274 +62,51 @@ export function useForceSimulation(
|
|
|
107
62
|
const [isRunning, setIsRunning] = useState(false);
|
|
108
63
|
const [alpha, setAlpha] = useState(1);
|
|
109
64
|
|
|
110
|
-
const simulationRef = useRef<d3.Simulation<
|
|
111
|
-
SimulationNode,
|
|
112
|
-
SimulationLink
|
|
113
|
-
> | null>(null);
|
|
65
|
+
const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
|
|
114
66
|
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
67
|
+
const forcesEnabledRef = useRef(true);
|
|
68
|
+
const originalForcesRef = useRef({
|
|
69
|
+
charge: chargeStrength,
|
|
70
|
+
link: linkStrength,
|
|
71
|
+
});
|
|
115
72
|
|
|
116
|
-
//
|
|
117
|
-
// when the actual identity/content of inputs change (not when parent passes
|
|
118
|
-
// new array references on each render).
|
|
73
|
+
// Unique keys to detect when to rebuild the simulation
|
|
119
74
|
const nodesKey = initialNodes.map((n) => n.id).join('|');
|
|
120
|
-
const linksKey =
|
|
75
|
+
const linksKey = initialLinks
|
|
121
76
|
.map((l) => {
|
|
122
|
-
const sourceId =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
: (l.source as SimulationNode)?.id;
|
|
126
|
-
const targetId =
|
|
127
|
-
typeof l.target === 'string'
|
|
128
|
-
? l.target
|
|
129
|
-
: (l.target as SimulationNode)?.id;
|
|
130
|
-
const linkType = (l as SimulationLink & { type?: string }).type || '';
|
|
77
|
+
const sourceId = typeof l.source === 'string' ? l.source : (l.source as SimulationNode)?.id;
|
|
78
|
+
const targetId = typeof l.target === 'string' ? l.target : (l.target as SimulationNode)?.id;
|
|
79
|
+
const linkType = (l as any).type || '';
|
|
131
80
|
return `${sourceId}->${targetId}:${linkType}`;
|
|
132
81
|
})
|
|
133
82
|
.join('|');
|
|
134
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Internal effect to manage simulation lifecycle
|
|
86
|
+
*/
|
|
135
87
|
useEffect(() => {
|
|
136
|
-
// Create a copy of nodes and links to avoid mutating the original data
|
|
137
88
|
const nodesCopy = initialNodes.map((node) => ({ ...node }));
|
|
138
89
|
const linksCopy = initialLinks.map((link) => ({ ...link }));
|
|
139
90
|
|
|
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
91
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Use deterministic but more widely spread positions based on index
|
|
147
|
-
const angle = (i * 2 * Math.PI) / nodesCopy.length;
|
|
148
|
-
// Larger seed radius to encourage an initial spread
|
|
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
|
|
92
|
+
seedCircularPositions(nodesCopy, width, height);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.warn('AIReady: Position seeding failed, using random fallback:', error);
|
|
159
95
|
seedRandomPositions(nodesCopy, width, height);
|
|
160
96
|
}
|
|
161
97
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
) as d3.Simulation<SimulationNode, SimulationLink>;
|
|
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
|
-
}
|
|
98
|
+
const simulation = d3.forceSimulation<SimulationNode, SimulationLink>(nodesCopy);
|
|
99
|
+
applySimulationForces(simulation, linksCopy);
|
|
100
|
+
configureSimulationParameters(simulation);
|
|
264
101
|
|
|
265
102
|
simulationRef.current = simulation;
|
|
266
103
|
|
|
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
|
-
}
|
|
292
|
-
|
|
293
|
-
// Update state on each tick. Batch updates via requestAnimationFrame to avoid
|
|
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
|
-
}
|
|
304
|
-
|
|
305
|
-
// If simulation alpha has cooled below the configured minimum, stop it to
|
|
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
|
-
};
|
|
104
|
+
const rafState = { rafId: null as number | null, lastUpdate: 0 };
|
|
105
|
+
setupTickHandler(simulation, nodesCopy, linksCopy, rafState);
|
|
343
106
|
|
|
344
|
-
simulation
|
|
107
|
+
setupStopTimer(simulation, nodesCopy, linksCopy);
|
|
345
108
|
|
|
346
|
-
|
|
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
|
-
};
|
|
109
|
+
return () => cleanupSimulation(simulation, rafState);
|
|
378
110
|
}, [
|
|
379
111
|
nodesKey,
|
|
380
112
|
linksKey,
|
|
@@ -395,86 +127,212 @@ export function useForceSimulation(
|
|
|
395
127
|
maxSimulationTimeMs,
|
|
396
128
|
]);
|
|
397
129
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Applies d3 forces to the simulation instance
|
|
132
|
+
*/
|
|
133
|
+
const applySimulationForces = (
|
|
134
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
135
|
+
linksCopy: SimulationLink[]
|
|
136
|
+
) => {
|
|
137
|
+
try {
|
|
138
|
+
const linkForce = d3
|
|
139
|
+
.forceLink<SimulationNode, SimulationLink>(linksCopy)
|
|
140
|
+
.id((d) => d.id)
|
|
141
|
+
.distance((d) => (d as any).distance ?? linkDistance)
|
|
142
|
+
.strength(linkStrength);
|
|
143
|
+
|
|
144
|
+
simulation
|
|
145
|
+
.force(FORCE_NAMES.LINK, linkForce)
|
|
146
|
+
.force(FORCE_NAMES.CHARGE, d3.forceManyBody().strength(chargeStrength))
|
|
147
|
+
.force(FORCE_NAMES.CENTER, d3.forceCenter(width / 2, height / 2).strength(centerStrength))
|
|
148
|
+
.force(
|
|
149
|
+
FORCE_NAMES.COLLISION,
|
|
150
|
+
d3.forceCollide<SimulationNode>().radius((d) => (d.size ?? 10) + collisionRadius).strength(collisionStrength)
|
|
151
|
+
)
|
|
152
|
+
.force(FORCE_NAMES.X, d3.forceX(width / 2).strength(Math.max(0.02, centerStrength * 0.5)))
|
|
153
|
+
.force(FORCE_NAMES.Y, d3.forceY(height / 2).strength(Math.max(0.02, centerStrength * 0.5)));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.warn('AIReady: Failed to configure simulation forces:', error);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Configures simulation decay and heat parameters
|
|
161
|
+
*/
|
|
162
|
+
const configureSimulationParameters = (simulation: d3.Simulation<SimulationNode, SimulationLink>) => {
|
|
163
|
+
simulation
|
|
164
|
+
.alphaDecay(alphaDecay)
|
|
165
|
+
.velocityDecay(velocityDecay)
|
|
166
|
+
.alphaMin(alphaMin)
|
|
167
|
+
.alphaTarget(alphaTarget)
|
|
168
|
+
.alpha(warmAlpha);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sets up a timer to force-stop the simulation after maxSimulationTimeMs
|
|
173
|
+
*/
|
|
174
|
+
const setupStopTimer = (
|
|
175
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
176
|
+
nodesCopy: SimulationNode[],
|
|
177
|
+
linksCopy: SimulationLink[]
|
|
178
|
+
) => {
|
|
179
|
+
if (stopTimeoutRef.current) {
|
|
180
|
+
clearTimeout(stopTimeoutRef.current);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (maxSimulationTimeMs > 0) {
|
|
184
|
+
stopTimeoutRef.current = setTimeout(() => {
|
|
185
|
+
safelyStopSimulation(simulation, nodesCopy, { stabilize: stabilizeOnStop });
|
|
186
|
+
updateStateAfterStop(nodesCopy, linksCopy, 0);
|
|
187
|
+
}, maxSimulationTimeMs);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Updates state variables after simulation stops
|
|
193
|
+
*/
|
|
194
|
+
const updateStateAfterStop = (
|
|
195
|
+
nodesCopy: SimulationNode[],
|
|
196
|
+
linksCopy: SimulationLink[],
|
|
197
|
+
currentAlpha: number
|
|
198
|
+
) => {
|
|
199
|
+
setIsRunning(false);
|
|
200
|
+
setAlpha(currentAlpha);
|
|
201
|
+
setNodes([...nodesCopy]);
|
|
202
|
+
setLinks([...linksCopy]);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Manages simulation ticks and React state sync
|
|
207
|
+
*/
|
|
208
|
+
const setupTickHandler = (
|
|
209
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
210
|
+
nodesCopy: SimulationNode[],
|
|
211
|
+
linksCopy: SimulationLink[],
|
|
212
|
+
rafState: { rafId: number | null; lastUpdate: number }
|
|
213
|
+
) => {
|
|
214
|
+
const handleTick = () => {
|
|
215
|
+
if (onTick) {
|
|
410
216
|
try {
|
|
411
|
-
|
|
412
|
-
} catch (
|
|
413
|
-
console.warn('
|
|
217
|
+
onTick(nodesCopy, linksCopy, simulation);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.warn('AIReady: Simulation onTick callback failed:', error);
|
|
414
220
|
}
|
|
415
|
-
stopTimeoutRef.current = null;
|
|
416
221
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
222
|
+
|
|
223
|
+
const currentAlpha = simulation.alpha();
|
|
224
|
+
if (currentAlpha <= alphaMin) {
|
|
225
|
+
safelyStopSimulation(simulation, nodesCopy, { stabilize: stabilizeOnStop });
|
|
226
|
+
updateStateAfterStop(nodesCopy, linksCopy, currentAlpha);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
syncStateOnTick(nodesCopy, linksCopy, currentAlpha, rafState);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
simulation.on(EVENT_NAMES.TICK, handleTick);
|
|
234
|
+
simulation.on(EVENT_NAMES.END, () => setIsRunning(false));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Syncs simulation results to React state using requestAnimationFrame
|
|
239
|
+
*/
|
|
240
|
+
const syncStateOnTick = (
|
|
241
|
+
nodesCopy: SimulationNode[],
|
|
242
|
+
linksCopy: SimulationLink[],
|
|
243
|
+
currentAlpha: number,
|
|
244
|
+
rafState: { rafId: number | null; lastUpdate: number }
|
|
245
|
+
) => {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
if (rafState.rafId === null && now - rafState.lastUpdate >= tickThrottleMs) {
|
|
248
|
+
rafState.rafId = requestAnimationFrame(() => {
|
|
249
|
+
rafState.rafId = null;
|
|
250
|
+
rafState.lastUpdate = Date.now();
|
|
251
|
+
setNodes([...nodesCopy]);
|
|
252
|
+
setLinks([...linksCopy]);
|
|
253
|
+
setAlpha(currentAlpha);
|
|
254
|
+
setIsRunning(currentAlpha > alphaMin);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Cleanup routine for simulation unmount or rebuild
|
|
261
|
+
*/
|
|
262
|
+
const cleanupSimulation = (
|
|
263
|
+
simulation: d3.Simulation<SimulationNode, SimulationLink>,
|
|
264
|
+
rafState: { rafId: number | null }
|
|
265
|
+
) => {
|
|
266
|
+
simulation.on(EVENT_NAMES.TICK, null);
|
|
267
|
+
if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current);
|
|
268
|
+
if (rafState.rafId !== null) cancelAnimationFrame(rafState.rafId);
|
|
269
|
+
simulation.stop();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Restart the simulation manually
|
|
274
|
+
*/
|
|
275
|
+
const restartSimulation = useCallback(() => {
|
|
276
|
+
const sim = simulationRef.current;
|
|
277
|
+
if (!sim) return;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
sim.alphaTarget(warmAlpha).restart();
|
|
281
|
+
setIsRunning(true);
|
|
282
|
+
if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current);
|
|
283
|
+
if (maxSimulationTimeMs > 0) {
|
|
284
|
+
stopTimeoutRef.current = setTimeout(() => {
|
|
285
|
+
sim.alpha(0);
|
|
286
|
+
sim.stop();
|
|
425
287
|
setIsRunning(false);
|
|
426
288
|
}, maxSimulationTimeMs);
|
|
427
289
|
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.warn('AIReady: Failed to restart simulation:', error);
|
|
428
292
|
}
|
|
429
|
-
};
|
|
293
|
+
}, [warmAlpha, maxSimulationTimeMs]);
|
|
430
294
|
|
|
431
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Stop the simulation manually
|
|
297
|
+
*/
|
|
298
|
+
const stopSimulation = useCallback(() => {
|
|
432
299
|
if (simulationRef.current) {
|
|
433
300
|
simulationRef.current.stop();
|
|
434
301
|
setIsRunning(false);
|
|
435
302
|
}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
const originalForcesRef = useRef({
|
|
439
|
-
charge: chargeStrength,
|
|
440
|
-
link: linkStrength,
|
|
441
|
-
collision: collisionStrength,
|
|
442
|
-
});
|
|
443
|
-
const forcesEnabledRef = useRef(true);
|
|
303
|
+
}, []);
|
|
444
304
|
|
|
445
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Enable or disable simulation forces
|
|
307
|
+
*/
|
|
308
|
+
const setForcesEnabled = useCallback((enabled: boolean) => {
|
|
446
309
|
const sim = simulationRef.current;
|
|
447
|
-
if (!sim) return;
|
|
448
|
-
|
|
449
|
-
if (forcesEnabledRef.current === enabled) return;
|
|
310
|
+
if (!sim || forcesEnabledRef.current === enabled) return;
|
|
311
|
+
|
|
450
312
|
forcesEnabledRef.current = enabled;
|
|
451
313
|
|
|
452
314
|
try {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
'charge'
|
|
456
|
-
) as d3.ForceManyBody<SimulationNode> | null;
|
|
457
|
-
if (charge && typeof charge.strength === 'function') {
|
|
315
|
+
const charge = sim.force(FORCE_NAMES.CHARGE) as d3.ForceManyBody<SimulationNode> | null;
|
|
316
|
+
if (charge) {
|
|
458
317
|
charge.strength(enabled ? originalForcesRef.current.charge : 0);
|
|
459
318
|
}
|
|
460
319
|
|
|
461
|
-
const link = sim.force(
|
|
462
|
-
|
|
463
|
-
d3.SimulationLinkDatum<SimulationNode>
|
|
464
|
-
> | null;
|
|
465
|
-
if (link && typeof link.strength === 'function') {
|
|
320
|
+
const link = sim.force(FORCE_NAMES.LINK) as d3.ForceLink<SimulationNode, SimulationLink> | null;
|
|
321
|
+
if (link) {
|
|
466
322
|
link.strength(enabled ? originalForcesRef.current.link : 0);
|
|
467
323
|
}
|
|
468
|
-
|
|
469
|
-
|
|
324
|
+
|
|
325
|
+
sim.alpha(warmAlpha).restart();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console.warn('AIReady: Failed to toggle simulation forces:', error);
|
|
470
328
|
}
|
|
471
|
-
};
|
|
329
|
+
}, [warmAlpha]);
|
|
472
330
|
|
|
473
331
|
return {
|
|
474
332
|
nodes,
|
|
475
333
|
links,
|
|
476
|
-
restart,
|
|
477
|
-
stop,
|
|
334
|
+
restart: restartSimulation,
|
|
335
|
+
stop: stopSimulation,
|
|
478
336
|
isRunning,
|
|
479
337
|
alpha,
|
|
480
338
|
setForcesEnabled,
|
|
@@ -483,58 +341,36 @@ export function useForceSimulation(
|
|
|
483
341
|
|
|
484
342
|
/**
|
|
485
343
|
* 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
344
|
*/
|
|
513
|
-
export function useDrag(
|
|
514
|
-
|
|
515
|
-
) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
345
|
+
export function useDrag(simulation: d3.Simulation<SimulationNode, any> | null | undefined) {
|
|
346
|
+
const handleDragStart = useCallback(
|
|
347
|
+
(event: any, node: SimulationNode) => {
|
|
348
|
+
if (!simulation) return;
|
|
349
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
350
|
+
node.fx = node.x;
|
|
351
|
+
node.fy = node.y;
|
|
352
|
+
},
|
|
353
|
+
[simulation]
|
|
354
|
+
);
|
|
522
355
|
|
|
523
|
-
const
|
|
356
|
+
const handleDragged = useCallback((event: any, node: SimulationNode) => {
|
|
524
357
|
node.fx = event.x;
|
|
525
358
|
node.fy = event.y;
|
|
526
|
-
};
|
|
359
|
+
}, []);
|
|
527
360
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
361
|
+
const handleDragEnd = useCallback(
|
|
362
|
+
(event: any, node: SimulationNode) => {
|
|
363
|
+
if (!simulation) return;
|
|
364
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
365
|
+
node.fx = null;
|
|
366
|
+
node.fy = null;
|
|
367
|
+
},
|
|
368
|
+
[simulation]
|
|
369
|
+
);
|
|
534
370
|
|
|
535
371
|
return {
|
|
536
|
-
onDragStart:
|
|
537
|
-
onDrag:
|
|
538
|
-
onDragEnd:
|
|
372
|
+
onDragStart: handleDragStart,
|
|
373
|
+
onDrag: handleDragged,
|
|
374
|
+
onDragEnd: handleDragEnd,
|
|
539
375
|
};
|
|
540
376
|
}
|