@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.
- package/dist/charts/ForceDirectedGraph.d.ts +19 -2
- package/dist/charts/ForceDirectedGraph.js +856 -207
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/hooks/useForceSimulation.d.ts +9 -1
- package/dist/hooks/useForceSimulation.js +210 -19
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +989 -205
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/smoke.test.js +4 -0
- package/src/__tests__/smoke.test.ts +5 -0
- package/src/charts/ForceDirectedGraph.tsx +601 -214
- package/src/charts/GraphControls.tsx +218 -0
- package/src/charts/LinkItem.tsx +74 -0
- package/src/charts/NodeItem.tsx +70 -0
- package/src/hooks/useForceSimulation.ts +259 -29
- package/src/index.ts +4 -0
|
@@ -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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
.
|
|
215
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|