@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.
- package/dist/charts/ForceDirectedGraph.d.ts +2 -0
- package/dist/charts/ForceDirectedGraph.js +489 -162
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/hooks/useForceSimulation.d.ts +5 -0
- package/dist/hooks/useForceSimulation.js +181 -19
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.js +489 -162
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/smoke.test.js +4 -0
- package/src/charts/ForceDirectedGraph.tsx +281 -277
- package/src/charts/LinkItem.tsx +74 -0
- package/src/charts/NodeItem.tsx +70 -0
- package/src/hooks/useForceSimulation.ts +220 -36
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
//
|
|
233
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
433
|
+
alphaTarget,
|
|
434
|
+
alphaMin,
|
|
435
|
+
stabilizeOnStop,
|
|
436
|
+
tickThrottleMs,
|
|
437
|
+
maxSimulationTimeMs,
|
|
267
438
|
]);
|
|
268
439
|
|
|
269
440
|
const restart = () => {
|
|
270
441
|
if (simulationRef.current) {
|
|
271
|
-
|
|
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
|
|