@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.
@@ -1,5 +1,16 @@
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 { 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
- import { useEffect, useRef, useState } from 'react';
11
- import * as d3 from 'd3';
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
- * 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
- * ```
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
- ): 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
- */
39
+ ): UseForceSimulationReturnExt {
83
40
  const {
84
- chargeStrength = -300,
85
- linkDistance = 100,
86
- linkStrength = 1,
87
- collisionStrength = 1,
88
- collisionRadius = 10,
89
- centerStrength = 0.1,
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 = 0.0228,
93
- velocityDecay = 0.4,
94
- alphaTarget = 0,
95
- warmAlpha = 0.3,
96
- alphaMin = 0.01,
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
- // 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,
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
- // 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).
73
+ // Unique keys to detect when to rebuild the simulation
119
74
  const nodesKey = initialNodes.map((n) => n.id).join('|');
120
- const linksKey = (initialLinks || [])
75
+ const linksKey = initialLinks
121
76
  .map((l) => {
122
- const sourceId =
123
- typeof l.source === 'string'
124
- ? l.source
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
- // 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
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
- // 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
- }
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
- // 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
- }
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.on('tick', tickHandler);
107
+ setupStopTimer(simulation, nodesCopy, linksCopy);
345
108
 
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
- };
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
- 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) {
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
- globalThis.clearTimeout(stopTimeoutRef.current);
412
- } catch (e) {
413
- console.warn('Failed to clear simulation timeout:', e);
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
- 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
- }
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
- const stop = () => {
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
- const setForcesEnabled = (enabled: boolean) => {
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
- // avoid repeated updates
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
- // 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') {
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('link') as d3.ForceLink<
462
- SimulationNode,
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
- } catch (e) {
469
- console.warn('Failed to toggle simulation forces:', e);
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
- simulation: d3.Simulation<SimulationNode, any> | null | undefined
515
- ) {
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
- };
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 dragged = (event: any, node: SimulationNode) => {
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 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
- };
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: dragStarted,
537
- onDrag: dragged,
538
- onDragEnd: dragEnded,
372
+ onDragStart: handleDragStart,
373
+ onDrag: handleDragged,
374
+ onDragEnd: handleDragEnd,
539
375
  };
540
376
  }