@aiready/components 0.1.0 → 0.1.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.
@@ -65,11 +65,54 @@ export interface ForceSimulationOptions {
65
65
  */
66
66
  alphaDecay?: number;
67
67
 
68
+ /**
69
+ * Alpha target controls the resting energy of the simulation. When set to 0
70
+ * the simulation will cool and stop moving once forces settle. Increase to
71
+ * keep the graph more dynamic.
72
+ * @default 0
73
+ */
74
+ alphaTarget?: number;
75
+
76
+ /**
77
+ * Warm alpha used when (re)starting the simulation to give it a small amount
78
+ * of energy. This mirrors the Observable example which sets a modest
79
+ * alphaTarget when dragging instead of forcing alpha to 1.
80
+ * @default 0.3
81
+ */
82
+ warmAlpha?: number;
83
+
84
+ /**
85
+ * Minimum alpha threshold below which the simulation is considered cooled
86
+ * and will stop. Increasing this makes the simulation stop earlier.
87
+ * @default 0.01
88
+ */
89
+ alphaMin?: number;
90
+
91
+ /**
92
+ * When true, zero node velocities and snap positions when the simulation
93
+ * stops to reduce residual jitter.
94
+ * @default true
95
+ */
96
+ stabilizeOnStop?: boolean;
97
+
98
+ /**
99
+ * Maximum time (ms) to allow the simulation to run after creation/restart.
100
+ * If the simulation hasn't cooled by this time, it will be force-stopped
101
+ * to prevent indefinite animation. Set to 0 to disable.
102
+ * @default 3000
103
+ */
104
+ maxSimulationTimeMs?: number;
105
+
68
106
  /**
69
107
  * Velocity decay (friction)
70
108
  * @default 0.4
71
109
  */
72
110
  velocityDecay?: number;
111
+
112
+ /**
113
+ * Optional tick callback invoked on each simulation tick with current nodes/links and the simulation instance
114
+ */
115
+ onTick?: (nodes: SimulationNode[], links: SimulationLink[], sim: d3.Simulation<SimulationNode, SimulationLink>) => void;
73
116
  }
74
117
 
75
118
  export interface UseForceSimulationReturn {
@@ -168,7 +211,7 @@ export function useForceSimulation(
168
211
  initialNodes: SimulationNode[],
169
212
  initialLinks: SimulationLink[],
170
213
  options: ForceSimulationOptions
171
- ): UseForceSimulationReturn {
214
+ ): UseForceSimulationReturn & { setForcesEnabled: (enabled: boolean) => void } {
172
215
  const {
173
216
  chargeStrength = -300,
174
217
  linkDistance = 100,
@@ -180,6 +223,18 @@ export function useForceSimulation(
180
223
  height,
181
224
  alphaDecay = 0.0228,
182
225
  velocityDecay = 0.4,
226
+ alphaTarget = 0,
227
+ warmAlpha = 0.3,
228
+ alphaMin = 0.01,
229
+ // @ts-ignore allow extra option
230
+ stabilizeOnStop = true,
231
+ onTick,
232
+ // Optional throttle in milliseconds for tick updates (reduce React re-renders)
233
+ // Lower values = smoother but more CPU; default ~30ms (~33fps)
234
+ // @ts-ignore allow extra option
235
+ tickThrottleMs = 33,
236
+ // @ts-ignore allow extra option
237
+ maxSimulationTimeMs = 3000,
183
238
  } = options;
184
239
 
185
240
  const [nodes, setNodes] = useState<SimulationNode[]>(initialNodes);
@@ -188,41 +243,160 @@ export function useForceSimulation(
188
243
  const [alpha, setAlpha] = useState(1);
189
244
 
190
245
  const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null);
246
+ const stopTimeoutRef = useRef<number | null>(null);
247
+
248
+ // Create lightweight keys for nodes/links so we only recreate the simulation
249
+ // when the actual identity/content of inputs change (not when parent passes
250
+ // new array references on each render).
251
+ const nodesKey = initialNodes.map((n) => n.id).join('|');
252
+ const linksKey = (initialLinks || []).map((l) => {
253
+ const s = typeof l.source === 'string' ? l.source : (l.source as any)?.id;
254
+ const t = typeof l.target === 'string' ? l.target : (l.target as any)?.id;
255
+ return `${s}->${t}:${(l as any).type || ''}`;
256
+ }).join('|');
191
257
 
192
258
  useEffect(() => {
193
259
  // Create a copy of nodes and links to avoid mutating the original data
194
260
  const nodesCopy = initialNodes.map((node) => ({ ...node }));
195
261
  const linksCopy = initialLinks.map((link) => ({ ...link }));
196
262
 
263
+ // ALWAYS seed initial positions to ensure nodes don't stack at origin
264
+ // This is critical for force-directed graphs to work properly
265
+ try {
266
+ // Always seed positions for all nodes when simulation is created
267
+ // This ensures nodes start spread out even if they have coordinates
268
+ nodesCopy.forEach((n, i) => {
269
+ // Use deterministic but more widely spread positions based on index
270
+ const angle = (i * 2 * Math.PI) / nodesCopy.length;
271
+ // Larger seed radius to encourage an initial spread
272
+ const radius = Math.min(width, height) * 0.45;
273
+ n.x = width / 2 + radius * Math.cos(angle);
274
+ n.y = height / 2 + radius * Math.sin(angle);
275
+ // Add very small random velocity to avoid large initial motion
276
+ (n as any).vx = (Math.random() - 0.5) * 2;
277
+ (n as any).vy = (Math.random() - 0.5) * 2;
278
+ });
279
+ } catch (e) {
280
+ // If error, fall back to random positions
281
+ nodesCopy.forEach((n) => {
282
+ n.x = Math.random() * width;
283
+ n.y = Math.random() * height;
284
+ (n as any).vx = (Math.random() - 0.5) * 10;
285
+ (n as any).vy = (Math.random() - 0.5) * 10;
286
+ });
287
+ }
288
+
197
289
  // Create the simulation
198
- const simulation = d3
199
- .forceSimulation<SimulationNode>(nodesCopy)
200
- .force(
201
- 'link',
202
- d3
203
- .forceLink<SimulationNode, SimulationLink>(linksCopy)
204
- .id((d) => d.id)
205
- .distance(linkDistance)
206
- .strength(linkStrength)
207
- )
208
- .force('charge', d3.forceManyBody().strength(chargeStrength))
209
- .force('center', d3.forceCenter(width / 2, height / 2).strength(centerStrength))
210
- .force(
211
- 'collision',
212
- d3.forceCollide<SimulationNode>().radius(collisionRadius).strength(collisionStrength)
213
- )
214
- .alphaDecay(alphaDecay)
215
- .velocityDecay(velocityDecay);
290
+ const simulation = (d3.forceSimulation(nodesCopy as any) as unknown) as d3.Simulation<SimulationNode, SimulationLink>;
291
+
292
+ // Configure link force separately to avoid using generic type args on d3 helpers
293
+ try {
294
+ const linkForce = (d3.forceLink(linksCopy as any) as unknown) as d3.ForceLink<SimulationNode, SimulationLink>;
295
+ linkForce.id((d: any) => d.id).distance((d: any) => (d && d.distance != null ? d.distance : linkDistance)).strength(linkStrength);
296
+ simulation.force('link', linkForce as any);
297
+ } catch (e) {
298
+ // fallback: attach a plain link force
299
+ try { simulation.force('link', d3.forceLink(linksCopy as any) as any); } catch (e) {}
300
+ }
301
+ ;
302
+
303
+ try {
304
+ simulation.force('charge', d3.forceManyBody().strength(chargeStrength) as any);
305
+ simulation.force('center', d3.forceCenter(width / 2, height / 2).strength(centerStrength) as any);
306
+ const collide = d3.forceCollide().radius((d: any) => {
307
+ const nodeSize = (d && d.size) ? d.size : 10;
308
+ return nodeSize + collisionRadius;
309
+ }).strength(collisionStrength as any) as any;
310
+ simulation.force('collision', collide);
311
+ simulation.force('x', d3.forceX(width / 2).strength(Math.max(0.02, centerStrength * 0.5)) as any);
312
+ simulation.force('y', d3.forceY(height / 2).strength(Math.max(0.02, centerStrength * 0.5)) as any);
313
+ simulation.alphaDecay(alphaDecay);
314
+ simulation.velocityDecay(velocityDecay);
315
+ simulation.alphaMin(alphaMin);
316
+ try { simulation.alphaTarget(alphaTarget); } catch (e) {}
317
+ try { simulation.alpha(warmAlpha); } catch (e) {}
318
+ } catch (e) {
319
+ // ignore force configuration errors
320
+ }
216
321
 
217
322
  simulationRef.current = simulation;
218
323
 
219
- // Update state on each tick
220
- simulation.on('tick', () => {
221
- setNodes([...nodesCopy]);
222
- setLinks([...linksCopy]);
223
- setAlpha(simulation.alpha());
224
- setIsRunning(simulation.alpha() > simulation.alphaMin());
225
- });
324
+ // Force-stop timeout to ensure simulation doesn't run forever.
325
+ if (stopTimeoutRef.current != null) {
326
+ try { (globalThis.clearTimeout as any)(stopTimeoutRef.current); } catch (e) {}
327
+ stopTimeoutRef.current = null;
328
+ }
329
+ if (maxSimulationTimeMs && maxSimulationTimeMs > 0) {
330
+ stopTimeoutRef.current = (globalThis.setTimeout as any)(() => {
331
+ try {
332
+ if (stabilizeOnStop) {
333
+ nodesCopy.forEach((n) => {
334
+ (n as any).vx = 0;
335
+ (n as any).vy = 0;
336
+ if (typeof n.x === 'number') n.x = Number(n.x.toFixed(3));
337
+ if (typeof n.y === 'number') n.y = Number(n.y.toFixed(3));
338
+ });
339
+ }
340
+ simulation.alpha(0);
341
+ simulation.stop();
342
+ } catch (e) {}
343
+ setIsRunning(false);
344
+ setNodes([...nodesCopy]);
345
+ setLinks([...linksCopy]);
346
+ }, maxSimulationTimeMs) as unknown as number;
347
+ }
348
+
349
+ // Update state on each tick. Batch updates via requestAnimationFrame to avoid
350
+ // excessive React re-renders which can cause visual flicker.
351
+ let rafId: number | null = null;
352
+ let lastUpdate = 0;
353
+ const tickHandler = () => {
354
+ try {
355
+ if (typeof onTick === 'function') onTick(nodesCopy, linksCopy, simulation);
356
+ } catch (e) {
357
+ // ignore user tick errors
358
+ }
359
+
360
+ // If simulation alpha has cooled below the configured minimum, stop it to
361
+ // ensure nodes don't drift indefinitely (acts as a hard-stop safeguard).
362
+ try {
363
+ if (simulation.alpha() <= (alphaMin as number)) {
364
+ try {
365
+ if (stabilizeOnStop) {
366
+ nodesCopy.forEach((n) => {
367
+ (n as any).vx = 0;
368
+ (n as any).vy = 0;
369
+ if (typeof n.x === 'number') n.x = Number(n.x.toFixed(3));
370
+ if (typeof n.y === 'number') n.y = Number(n.y.toFixed(3));
371
+ });
372
+ }
373
+ simulation.stop();
374
+ } catch (e) {}
375
+ setAlpha(simulation.alpha());
376
+ setIsRunning(false);
377
+ setNodes([...nodesCopy]);
378
+ setLinks([...linksCopy]);
379
+ return;
380
+ }
381
+ } catch (e) {
382
+ // ignore
383
+ }
384
+
385
+ const now = Date.now();
386
+ const shouldUpdate = now - lastUpdate >= (tickThrottleMs as number);
387
+ if (rafId == null && shouldUpdate) {
388
+ rafId = (globalThis.requestAnimationFrame || ((cb: FrameRequestCallback) => setTimeout(cb, 16)))(() => {
389
+ rafId = null;
390
+ lastUpdate = Date.now();
391
+ setNodes([...nodesCopy]);
392
+ setLinks([...linksCopy]);
393
+ setAlpha(simulation.alpha());
394
+ setIsRunning(simulation.alpha() > simulation.alphaMin());
395
+ }) as unknown as number;
396
+ }
397
+ };
398
+
399
+ simulation.on('tick', tickHandler);
226
400
 
227
401
  simulation.on('end', () => {
228
402
  setIsRunning(false);
@@ -230,11 +404,22 @@ export function useForceSimulation(
230
404
 
231
405
  // Cleanup on unmount
232
406
  return () => {
407
+ try {
408
+ simulation.on('tick', null as any);
409
+ } catch (e) {}
410
+ if (stopTimeoutRef.current != null) {
411
+ try { (globalThis.clearTimeout as any)(stopTimeoutRef.current); } catch (e) {}
412
+ stopTimeoutRef.current = null;
413
+ }
414
+ if (rafId != null) {
415
+ try { (globalThis.cancelAnimationFrame || ((id: number) => clearTimeout(id)))(rafId); } catch (e) {}
416
+ rafId = null;
417
+ }
233
418
  simulation.stop();
234
419
  };
235
420
  }, [
236
- initialNodes,
237
- initialLinks,
421
+ nodesKey,
422
+ linksKey,
238
423
  chargeStrength,
239
424
  linkDistance,
240
425
  linkStrength,
@@ -245,12 +430,30 @@ export function useForceSimulation(
245
430
  height,
246
431
  alphaDecay,
247
432
  velocityDecay,
433
+ alphaTarget,
434
+ alphaMin,
435
+ stabilizeOnStop,
436
+ tickThrottleMs,
437
+ maxSimulationTimeMs,
248
438
  ]);
249
439
 
250
440
  const restart = () => {
251
441
  if (simulationRef.current) {
252
- simulationRef.current.alpha(1).restart();
442
+ // Reheat the simulation to a modest alpha target rather than forcing
443
+ // full heat; this matches the Observable pattern and helps stability.
444
+ try { simulationRef.current.alphaTarget(warmAlpha).restart(); } catch (e) { simulationRef.current.restart(); }
253
445
  setIsRunning(true);
446
+ // Reset safety timeout when simulation is manually restarted
447
+ if (stopTimeoutRef.current != null) {
448
+ try { (globalThis.clearTimeout as any)(stopTimeoutRef.current); } catch (e) {}
449
+ stopTimeoutRef.current = null;
450
+ }
451
+ if (maxSimulationTimeMs && maxSimulationTimeMs > 0) {
452
+ stopTimeoutRef.current = (globalThis.setTimeout as any)(() => {
453
+ try { simulationRef.current?.alpha(0); simulationRef.current?.stop(); } catch (e) {}
454
+ setIsRunning(false);
455
+ }, maxSimulationTimeMs) as unknown as number;
456
+ }
254
457
  }
255
458
  };
256
459
 
@@ -261,6 +464,32 @@ export function useForceSimulation(
261
464
  }
262
465
  };
263
466
 
467
+ const originalForcesRef = useRef({ charge: chargeStrength, link: linkStrength, collision: collisionStrength });
468
+ const forcesEnabledRef = useRef(true);
469
+
470
+ const setForcesEnabled = (enabled: boolean) => {
471
+ const sim = simulationRef.current;
472
+ if (!sim) return;
473
+ // avoid repeated updates
474
+ if (forcesEnabledRef.current === enabled) return;
475
+ forcesEnabledRef.current = enabled;
476
+
477
+ try {
478
+ // Only toggle charge and link forces to avoid collapse; keep collision/centering
479
+ const charge: any = sim.force('charge');
480
+ if (charge && typeof charge.strength === 'function') {
481
+ charge.strength(enabled ? originalForcesRef.current.charge : 0);
482
+ }
483
+
484
+ const link: any = sim.force('link');
485
+ if (link && typeof link.strength === 'function') {
486
+ link.strength(enabled ? originalForcesRef.current.link : 0);
487
+ }
488
+ } catch (e) {
489
+ // ignore
490
+ }
491
+ };
492
+
264
493
  return {
265
494
  nodes,
266
495
  links,
@@ -268,6 +497,7 @@ export function useForceSimulation(
268
497
  stop,
269
498
  isRunning,
270
499
  alpha,
500
+ setForcesEnabled,
271
501
  };
272
502
  }
273
503
 
package/src/index.ts CHANGED
@@ -49,3 +49,7 @@ export {
49
49
  type GraphLink,
50
50
  type ForceDirectedGraphProps,
51
51
  } from './charts/ForceDirectedGraph';
52
+ export {
53
+ GraphControls,
54
+ type GraphControlsProps,
55
+ } from './charts/GraphControls';