@aiready/components 0.1.3 → 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.
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import type { GraphLink, GraphNode } from './ForceDirectedGraph';
3
+
4
+ export interface LinkItemProps {
5
+ link: GraphLink;
6
+ onClick?: (l: GraphLink) => void;
7
+ defaultWidth?: number;
8
+ showLabel?: boolean;
9
+ nodes?: GraphNode[]; // Optional nodes array to resolve string IDs to node objects
10
+ }
11
+
12
+ export const LinkItem: React.FC<LinkItemProps> = ({ link, onClick, defaultWidth, showLabel = true, nodes = [] }) => {
13
+ const src = (link.source as any)?.id ?? (typeof link.source === 'string' ? link.source : undefined);
14
+ const tgt = (link.target as any)?.id ?? (typeof link.target === 'string' ? link.target : undefined);
15
+
16
+ // Helper to get node position from source/target (which could be node object or string ID)
17
+ const getNodePosition = (nodeOrId: string | GraphNode): { x: number; y: number } | null => {
18
+ if (typeof nodeOrId === 'object' && nodeOrId !== null) {
19
+ // It's a node object
20
+ const node = nodeOrId as GraphNode;
21
+ return { x: node.x ?? 0, y: node.y ?? 0 };
22
+ } else if (typeof nodeOrId === 'string') {
23
+ // It's a string ID, try to find in nodes array
24
+ const found = nodes.find(n => n.id === nodeOrId);
25
+ if (found) return { x: found.x ?? 0, y: found.y ?? 0 };
26
+ }
27
+ return null;
28
+ };
29
+
30
+ const sourcePos = getNodePosition(link.source);
31
+ const targetPos = getNodePosition(link.target);
32
+
33
+ // If we can't get positions, render nothing (or a placeholder)
34
+ if (!sourcePos || !targetPos) {
35
+ return null;
36
+ }
37
+
38
+ // Calculate midpoint for label positioning
39
+ const midX = (sourcePos.x + targetPos.x) / 2;
40
+ const midY = (sourcePos.y + targetPos.y) / 2;
41
+
42
+ return (
43
+ <g>
44
+ <line
45
+ x1={sourcePos.x}
46
+ y1={sourcePos.y}
47
+ x2={targetPos.x}
48
+ y2={targetPos.y}
49
+ data-source={src}
50
+ data-target={tgt}
51
+ stroke={link.color}
52
+ strokeWidth={link.width ?? defaultWidth ?? 1}
53
+ opacity={0.6}
54
+ className="cursor-pointer transition-opacity hover:opacity-100"
55
+ onClick={() => onClick?.(link)}
56
+ />
57
+ {showLabel && link.label && (
58
+ <text
59
+ x={midX}
60
+ y={midY}
61
+ fill="#666"
62
+ fontSize="10"
63
+ textAnchor="middle"
64
+ dominantBaseline="middle"
65
+ pointerEvents="none"
66
+ >
67
+ {link.label}
68
+ </text>
69
+ )}
70
+ </g>
71
+ );
72
+ };
73
+
74
+ export default LinkItem;
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import type { GraphNode } from './ForceDirectedGraph';
3
+
4
+ export interface NodeItemProps {
5
+ node: GraphNode;
6
+ isSelected: boolean;
7
+ isHovered: boolean;
8
+ pinned: boolean;
9
+ defaultNodeSize: number;
10
+ defaultNodeColor: string;
11
+ showLabel?: boolean;
12
+ onClick?: (n: GraphNode) => void;
13
+ onDoubleClick?: (e: React.MouseEvent, n: GraphNode) => void;
14
+ onMouseEnter?: (n: GraphNode) => void;
15
+ onMouseLeave?: () => void;
16
+ onMouseDown?: (e: React.MouseEvent, n: GraphNode) => void;
17
+ }
18
+
19
+ export const NodeItem: React.FC<NodeItemProps> = ({
20
+ node,
21
+ isSelected,
22
+ isHovered,
23
+ pinned,
24
+ defaultNodeSize,
25
+ defaultNodeColor,
26
+ showLabel = true,
27
+ onClick,
28
+ onDoubleClick,
29
+ onMouseEnter,
30
+ onMouseLeave,
31
+ onMouseDown,
32
+ }) => {
33
+ const nodeSize = node.size || defaultNodeSize;
34
+ const nodeColor = node.color || defaultNodeColor;
35
+
36
+ const x = node.x ?? 0;
37
+ const y = node.y ?? 0;
38
+
39
+ return (
40
+ <g
41
+ key={node.id}
42
+ className="cursor-pointer node"
43
+ data-id={node.id}
44
+ transform={`translate(${x},${y})`}
45
+ onClick={() => onClick?.(node)}
46
+ onDoubleClick={(e) => onDoubleClick?.(e, node)}
47
+ onMouseEnter={() => onMouseEnter?.(node)}
48
+ onMouseLeave={() => onMouseLeave?.()}
49
+ onMouseDown={(e) => onMouseDown?.(e, node)}
50
+ >
51
+ <circle
52
+ r={nodeSize}
53
+ fill={nodeColor}
54
+ stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
55
+ strokeWidth={pinned ? 3 : isSelected ? 2.5 : isHovered ? 2 : 1.5}
56
+ opacity={isHovered || isSelected ? 1 : 0.9}
57
+ />
58
+ {pinned && (
59
+ <circle r={nodeSize + 4} fill="none" stroke="#ff6b6b" strokeWidth={1} opacity={0.5} className="pointer-events-none" />
60
+ )}
61
+ {showLabel && node.label && (
62
+ <text y={nodeSize + 15} fill="#333" fontSize="12" textAnchor="middle" dominantBaseline="middle" pointerEvents="none" className="select-none">
63
+ {node.label}
64
+ </text>
65
+ )}
66
+ </g>
67
+ );
68
+ };
69
+
70
+ export default NodeItem;
@@ -65,6 +65,44 @@ 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
@@ -185,7 +223,18 @@ export function useForceSimulation(
185
223
  height,
186
224
  alphaDecay = 0.0228,
187
225
  velocityDecay = 0.4,
226
+ alphaTarget = 0,
227
+ warmAlpha = 0.3,
228
+ alphaMin = 0.01,
229
+ // @ts-ignore allow extra option
230
+ stabilizeOnStop = true,
188
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,
189
238
  } = options;
190
239
 
191
240
  const [nodes, setNodes] = useState<SimulationNode[]>(initialNodes);
@@ -194,53 +243,160 @@ export function useForceSimulation(
194
243
  const [alpha, setAlpha] = useState(1);
195
244
 
196
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('|');
197
257
 
198
258
  useEffect(() => {
199
259
  // Create a copy of nodes and links to avoid mutating the original data
200
260
  const nodesCopy = initialNodes.map((node) => ({ ...node }));
201
261
  const linksCopy = initialLinks.map((link) => ({ ...link }));
202
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
+
203
289
  // Create the simulation
204
- const simulation = d3
205
- .forceSimulation<SimulationNode>(nodesCopy)
206
- .force(
207
- 'link',
208
- d3
209
- .forceLink<SimulationNode, SimulationLink>(linksCopy)
210
- .id((d) => d.id)
211
- .distance((d: any) => (d && d.distance != null ? d.distance : linkDistance))
212
- .strength(linkStrength)
213
- )
214
- .force('charge', d3.forceManyBody().strength(chargeStrength))
215
- .force('center', d3.forceCenter(width / 2, height / 2).strength(centerStrength))
216
- .force(
217
- 'collision',
218
- d3
219
- .forceCollide<SimulationNode>()
220
- .radius((d: any) => {
221
- // Use node-specific size when available and add configured padding to ensure minimum spacing
222
- const nodeSize = (d && d.size) ? d.size : 10;
223
- return nodeSize + collisionRadius;
224
- })
225
- .strength(collisionStrength)
226
- )
227
- .alphaDecay(alphaDecay)
228
- .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
+ }
229
321
 
230
322
  simulationRef.current = simulation;
231
323
 
232
- // Update state on each tick
233
- simulation.on('tick', () => {
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 = () => {
234
354
  try {
235
355
  if (typeof onTick === 'function') onTick(nodesCopy, linksCopy, simulation);
236
356
  } catch (e) {
237
357
  // ignore user tick errors
238
358
  }
239
- setNodes([...nodesCopy]);
240
- setLinks([...linksCopy]);
241
- setAlpha(simulation.alpha());
242
- setIsRunning(simulation.alpha() > simulation.alphaMin());
243
- });
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);
244
400
 
245
401
  simulation.on('end', () => {
246
402
  setIsRunning(false);
@@ -248,11 +404,22 @@ export function useForceSimulation(
248
404
 
249
405
  // Cleanup on unmount
250
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
+ }
251
418
  simulation.stop();
252
419
  };
253
420
  }, [
254
- initialNodes,
255
- initialLinks,
421
+ nodesKey,
422
+ linksKey,
256
423
  chargeStrength,
257
424
  linkDistance,
258
425
  linkStrength,
@@ -263,13 +430,30 @@ export function useForceSimulation(
263
430
  height,
264
431
  alphaDecay,
265
432
  velocityDecay,
266
- onTick,
433
+ alphaTarget,
434
+ alphaMin,
435
+ stabilizeOnStop,
436
+ tickThrottleMs,
437
+ maxSimulationTimeMs,
267
438
  ]);
268
439
 
269
440
  const restart = () => {
270
441
  if (simulationRef.current) {
271
- 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(); }
272
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
+ }
273
457
  }
274
458
  };
275
459