@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.
@@ -1,5 +1,20 @@
1
- // Import helpers from separate module
2
- import { stabilizeNodes, seedRandomPositions } from './simulation-helpers';
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
- import { useEffect, useRef, useState } from 'react';
11
- import * as d3 from 'd3';
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
- * Hook for managing d3-force simulations
15
- * Automatically handles simulation lifecycle, tick updates, and cleanup
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
- ): UseForceSimulationReturn & { setForcesEnabled: (enabled: boolean) => void } {
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 = -300,
85
- linkDistance = 100,
86
- linkStrength = 1,
87
- collisionStrength = 1,
88
- collisionRadius = 10,
89
- centerStrength = 0.1,
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 = 0.0228,
93
- velocityDecay = 0.4,
94
- alphaTarget = 0,
95
- warmAlpha = 0.3,
96
- alphaMin = 0.01,
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
- // Optional throttle in milliseconds for tick updates (reduce React re-renders)
99
- // Lower values = smoother but more CPU; default ~30ms (~33fps)
100
- stabilizeOnStop = true,
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
- // Create lightweight keys for nodes/links so we only recreate the simulation
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 = (initialLinks || [])
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 SimulationLink & { type?: string }).type || '';
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
- // Always seed positions for all nodes when simulation is created
144
- // This ensures nodes start spread out even if they have coordinates
145
- nodesCopy.forEach((n, i) => {
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
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
- // Create the simulation
163
- const simulation = d3.forceSimulation(
164
- nodesCopy as SimulationNode[]
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
- }
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
- // Force-stop timeout to ensure simulation doesn't run forever.
268
- if (stopTimeoutRef.current != null) {
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
- // 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
- }
125
+ setupStopTimer(simulation, nodesCopy, linksCopy);
304
126
 
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
- };
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
- const restart = () => {
399
- if (simulationRef.current) {
400
- // Reheat the simulation to a modest alpha target rather than forcing
401
- // full heat; this matches the Observable pattern and helps stability.
402
- try {
403
- simulationRef.current.alphaTarget(warmAlpha).restart();
404
- } catch {
405
- simulationRef.current.restart();
406
- }
407
- setIsRunning(true);
408
- // Reset safety timeout when simulation is manually restarted
409
- if (stopTimeoutRef.current != null) {
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
- globalThis.clearTimeout(stopTimeoutRef.current);
412
- } catch (e) {
413
- console.warn('Failed to clear simulation timeout:', e);
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
- if (maxSimulationTimeMs && maxSimulationTimeMs > 0) {
418
- stopTimeoutRef.current = globalThis.setTimeout(() => {
419
- try {
420
- simulationRef.current?.alpha(0);
421
- simulationRef.current?.stop();
422
- } catch (e) {
423
- console.warn('Failed to stop simulation:', e);
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
- const stop = () => {
432
- if (simulationRef.current) {
433
- simulationRef.current.stop();
434
- setIsRunning(false);
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
- const originalForcesRef = useRef({
439
- charge: chargeStrength,
440
- link: linkStrength,
441
- collision: collisionStrength,
442
- });
443
- const forcesEnabledRef = useRef(true);
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
- const setForcesEnabled = (enabled: boolean) => {
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
- // Only toggle charge and link forces to avoid collapse; keep collision/centering
454
- const charge = sim.force(
455
- 'charge'
456
- ) as d3.ForceManyBody<SimulationNode> | null;
457
- if (charge && typeof charge.strength === 'function') {
458
- charge.strength(enabled ? originalForcesRef.current.charge : 0);
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
- const link = sim.force('link') as d3.ForceLink<
462
- SimulationNode,
463
- d3.SimulationLinkDatum<SimulationNode>
464
- > | null;
465
- if (link && typeof link.strength === 'function') {
466
- link.strength(enabled ? originalForcesRef.current.link : 0);
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 dragStarted = (event: any, node: SimulationNode) => {
517
- if (!simulation) return;
518
- if (!event.active) simulation.alphaTarget(0.3).restart();
519
- node.fx = node.x;
520
- node.fy = node.y;
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 dragged = (event: any, node: SimulationNode) => {
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 dragEnded = (event: any, node: SimulationNode) => {
529
- if (!simulation) return;
530
- if (!event.active) simulation.alphaTarget(0);
531
- node.fx = null;
532
- node.fy = null;
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: dragStarted,
537
- onDrag: dragged,
538
- onDragEnd: dragEnded,
421
+ onDragStart: handleDragStart,
422
+ onDrag: handleDragged,
423
+ onDragEnd: handleDragEnd,
539
424
  };
540
425
  }