@aiready/components 0.1.0 → 0.1.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.
- package/dist/charts/ForceDirectedGraph.d.ts +17 -2
- package/dist/charts/ForceDirectedGraph.js +518 -196
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/hooks/useForceSimulation.d.ts +4 -1
- package/dist/hooks/useForceSimulation.js +34 -5
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +651 -194
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/smoke.test.ts +5 -0
- package/src/charts/ForceDirectedGraph.tsx +441 -58
- package/src/charts/GraphControls.tsx +218 -0
- package/src/hooks/useForceSimulation.ts +49 -3
- package/src/index.ts +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
|
2
2
|
import * as d3 from 'd3';
|
|
3
3
|
import {
|
|
4
4
|
useForceSimulation,
|
|
@@ -22,6 +22,38 @@ export interface GraphLink extends SimulationLink {
|
|
|
22
22
|
label?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface ForceDirectedGraphHandle {
|
|
26
|
+
/**
|
|
27
|
+
* Pin all nodes in place
|
|
28
|
+
*/
|
|
29
|
+
pinAll: () => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unpin all nodes (release constraints)
|
|
33
|
+
*/
|
|
34
|
+
unpinAll: () => void;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reset all nodes to auto-layout (unpin and restart simulation)
|
|
38
|
+
*/
|
|
39
|
+
resetLayout: () => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fit all nodes in the current view
|
|
43
|
+
*/
|
|
44
|
+
fitView: () => void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get currently pinned node IDs
|
|
48
|
+
*/
|
|
49
|
+
getPinnedNodes: () => string[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Toggle dragging mode
|
|
53
|
+
*/
|
|
54
|
+
setDragMode: (enabled: boolean) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
export interface ForceDirectedGraphProps {
|
|
26
58
|
/**
|
|
27
59
|
* Array of nodes to display
|
|
@@ -125,40 +157,242 @@ export interface ForceDirectedGraphProps {
|
|
|
125
157
|
* Additional CSS classes
|
|
126
158
|
*/
|
|
127
159
|
className?: string;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Manual layout mode: disables forces, allows free dragging
|
|
163
|
+
* @default false
|
|
164
|
+
*/
|
|
165
|
+
manualLayout?: boolean;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Callback when manual layout mode is toggled
|
|
169
|
+
*/
|
|
170
|
+
onManualLayoutChange?: (enabled: boolean) => void;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Package bounds computed by the parent (pack layout): map of `pkg:group` -> {x,y,r}
|
|
174
|
+
*/
|
|
175
|
+
packageBounds?: Record<string, { x: number; y: number; r: number }>;
|
|
128
176
|
}
|
|
129
177
|
|
|
130
|
-
export const ForceDirectedGraph
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
|
|
179
|
+
(
|
|
180
|
+
{
|
|
181
|
+
nodes: initialNodes,
|
|
182
|
+
links: initialLinks,
|
|
183
|
+
width,
|
|
184
|
+
height,
|
|
185
|
+
simulationOptions,
|
|
186
|
+
enableZoom = true,
|
|
187
|
+
enableDrag = true,
|
|
188
|
+
onNodeClick,
|
|
189
|
+
onNodeHover,
|
|
190
|
+
onLinkClick,
|
|
191
|
+
selectedNodeId,
|
|
192
|
+
hoveredNodeId,
|
|
193
|
+
defaultNodeColor = '#69b3a2',
|
|
194
|
+
defaultNodeSize = 10,
|
|
195
|
+
defaultLinkColor = '#999',
|
|
196
|
+
defaultLinkWidth = 1,
|
|
197
|
+
showNodeLabels = true,
|
|
198
|
+
showLinkLabels = false,
|
|
199
|
+
className,
|
|
200
|
+
manualLayout = false,
|
|
201
|
+
onManualLayoutChange,
|
|
202
|
+
packageBounds,
|
|
203
|
+
},
|
|
204
|
+
ref
|
|
205
|
+
) => {
|
|
151
206
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
152
207
|
const gRef = useRef<SVGGElement>(null);
|
|
153
208
|
const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
|
|
209
|
+
const dragNodeRef = useRef<GraphNode | null>(null);
|
|
210
|
+
const dragActiveRef = useRef(false);
|
|
211
|
+
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
|
|
212
|
+
const internalDragEnabledRef = useRef(enableDrag);
|
|
213
|
+
|
|
214
|
+
// Update the ref when enableDrag prop changes
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
internalDragEnabledRef.current = enableDrag;
|
|
217
|
+
}, [enableDrag]);
|
|
218
|
+
|
|
219
|
+
// Initialize simulation with manualLayout mode
|
|
220
|
+
const onTick = (nodesCopy: any[], _linksCopy: any[], _sim: any) => {
|
|
221
|
+
const bounds = packageBounds && Object.keys(packageBounds).length ? packageBounds : undefined;
|
|
222
|
+
// fallback: if parent didn't provide packageBounds, compute locally from initialNodes
|
|
223
|
+
let effectiveBounds = bounds;
|
|
224
|
+
if (!effectiveBounds) {
|
|
225
|
+
try {
|
|
226
|
+
const counts: Record<string, number> = {};
|
|
227
|
+
(initialNodes || []).forEach((n: any) => {
|
|
228
|
+
if (n && n.kind === 'file') {
|
|
229
|
+
const g = n.packageGroup || 'root';
|
|
230
|
+
counts[g] = (counts[g] || 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
const children = Object.keys(counts).map((k) => ({ name: k, value: counts[k] }));
|
|
234
|
+
if (children.length > 0) {
|
|
235
|
+
const root = d3.hierarchy<any>({ children } as any).sum((d: any) => d.value as number);
|
|
236
|
+
const pack = d3.pack().size([width, height]).padding(30);
|
|
237
|
+
const packed = pack(root);
|
|
238
|
+
const map: Record<string, { x: number; y: number; r: number }> = {};
|
|
239
|
+
if (packed.children) {
|
|
240
|
+
packed.children.forEach((c: any) => {
|
|
241
|
+
map[`pkg:${c.data.name}`] = { x: c.x, y: c.y, r: c.r * 0.95 };
|
|
242
|
+
});
|
|
243
|
+
effectiveBounds = map;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
// ignore fallback errors
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!effectiveBounds) return;
|
|
251
|
+
try {
|
|
252
|
+
Object.values(nodesCopy).forEach((n: any) => {
|
|
253
|
+
if (!n) return;
|
|
254
|
+
// only constrain file nodes (package nodes have their own fx/fy)
|
|
255
|
+
if (n.kind === 'package') return;
|
|
256
|
+
const pkg = n.packageGroup;
|
|
257
|
+
if (!pkg) return;
|
|
258
|
+
const bound = effectiveBounds[`pkg:${pkg}`];
|
|
259
|
+
if (!bound) return;
|
|
260
|
+
const margin = (n.size || 10) + 12;
|
|
261
|
+
const dx = (n.x || 0) - bound.x;
|
|
262
|
+
const dy = (n.y || 0) - bound.y;
|
|
263
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
264
|
+
const maxDist = Math.max(1, bound.r - margin);
|
|
265
|
+
if (dist > maxDist) {
|
|
266
|
+
const desiredX = bound.x + dx * (maxDist / dist);
|
|
267
|
+
const desiredY = bound.y + dy * (maxDist / dist);
|
|
268
|
+
// apply a soft corrective velocity toward the desired position
|
|
269
|
+
const softness = 0.08;
|
|
270
|
+
n.vx = (n.vx || 0) + (desiredX - n.x) * softness;
|
|
271
|
+
n.vy = (n.vy || 0) + (desiredY - n.y) * softness;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// ignore
|
|
276
|
+
}
|
|
277
|
+
};
|
|
154
278
|
|
|
155
|
-
|
|
156
|
-
const { nodes, links, restart } = useForceSimulation(initialNodes, initialLinks, {
|
|
279
|
+
const { nodes, links, restart, stop, setForcesEnabled } = useForceSimulation(initialNodes, initialLinks, {
|
|
157
280
|
width,
|
|
158
281
|
height,
|
|
282
|
+
chargeStrength: manualLayout ? 0 : undefined,
|
|
283
|
+
onTick,
|
|
159
284
|
...simulationOptions,
|
|
160
285
|
});
|
|
161
286
|
|
|
287
|
+
// If package bounds are provided, add a tick-time clamp via the hook's onTick option
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (!packageBounds) return;
|
|
290
|
+
// nothing to do here because the hook will call onTick passed in creation; we need to recreate simulation to use onTick
|
|
291
|
+
// So restart the simulation to pick up potential changes in node bounds.
|
|
292
|
+
try { restart(); } catch (e) {}
|
|
293
|
+
}, [packageBounds, restart]);
|
|
294
|
+
|
|
295
|
+
// If manual layout is enabled or any nodes are pinned, disable forces
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
try {
|
|
298
|
+
if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
|
|
299
|
+
else setForcesEnabled(true);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// ignore
|
|
302
|
+
}
|
|
303
|
+
}, [manualLayout, pinnedNodes, setForcesEnabled]);
|
|
304
|
+
|
|
305
|
+
// Expose imperative handle for parent components
|
|
306
|
+
useImperativeHandle(
|
|
307
|
+
ref,
|
|
308
|
+
() => ({
|
|
309
|
+
pinAll: () => {
|
|
310
|
+
const newPinned = new Set<string>();
|
|
311
|
+
nodes.forEach((node) => {
|
|
312
|
+
node.fx = node.x;
|
|
313
|
+
node.fy = node.y;
|
|
314
|
+
newPinned.add(node.id);
|
|
315
|
+
});
|
|
316
|
+
setPinnedNodes(newPinned);
|
|
317
|
+
restart();
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
unpinAll: () => {
|
|
321
|
+
nodes.forEach((node) => {
|
|
322
|
+
node.fx = null;
|
|
323
|
+
node.fy = null;
|
|
324
|
+
});
|
|
325
|
+
setPinnedNodes(new Set());
|
|
326
|
+
restart();
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
resetLayout: () => {
|
|
330
|
+
nodes.forEach((node) => {
|
|
331
|
+
node.fx = null;
|
|
332
|
+
node.fy = null;
|
|
333
|
+
});
|
|
334
|
+
setPinnedNodes(new Set());
|
|
335
|
+
restart();
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
fitView: () => {
|
|
339
|
+
if (!svgRef.current || !nodes.length) return;
|
|
340
|
+
|
|
341
|
+
// Calculate bounds
|
|
342
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
343
|
+
nodes.forEach((node) => {
|
|
344
|
+
if (node.x !== undefined && node.y !== undefined) {
|
|
345
|
+
const size = node.size || 10;
|
|
346
|
+
minX = Math.min(minX, node.x - size);
|
|
347
|
+
maxX = Math.max(maxX, node.x + size);
|
|
348
|
+
minY = Math.min(minY, node.y - size);
|
|
349
|
+
maxY = Math.max(maxY, node.y + size);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!isFinite(minX)) return;
|
|
354
|
+
|
|
355
|
+
const padding = 40;
|
|
356
|
+
const nodeWidth = maxX - minX;
|
|
357
|
+
const nodeHeight = maxY - minY;
|
|
358
|
+
const scale = Math.min(
|
|
359
|
+
(width - padding * 2) / nodeWidth,
|
|
360
|
+
(height - padding * 2) / nodeHeight,
|
|
361
|
+
10
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const centerX = (minX + maxX) / 2;
|
|
365
|
+
const centerY = (minY + maxY) / 2;
|
|
366
|
+
|
|
367
|
+
const x = width / 2 - centerX * scale;
|
|
368
|
+
const y = height / 2 - centerY * scale;
|
|
369
|
+
|
|
370
|
+
if (gRef.current && svgRef.current) {
|
|
371
|
+
const svg = d3.select(svgRef.current);
|
|
372
|
+
const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
|
|
373
|
+
svg.transition().duration(300).call(d3.zoom<SVGSVGElement, unknown>().transform as any, newTransform);
|
|
374
|
+
setTransform(newTransform);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
getPinnedNodes: () => Array.from(pinnedNodes),
|
|
379
|
+
|
|
380
|
+
setDragMode: (enabled: boolean) => {
|
|
381
|
+
internalDragEnabledRef.current = enabled;
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
[nodes, pinnedNodes, restart, width, height]
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Notify parent when manual layout mode changes (uses the prop so it's not unused)
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
try {
|
|
390
|
+
if (typeof onManualLayoutChange === 'function') onManualLayoutChange(manualLayout);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// ignore errors from callbacks
|
|
393
|
+
}
|
|
394
|
+
}, [manualLayout, onManualLayoutChange]);
|
|
395
|
+
|
|
162
396
|
// Set up zoom behavior
|
|
163
397
|
useEffect(() => {
|
|
164
398
|
if (!enableZoom || !svgRef.current || !gRef.current) return;
|
|
@@ -181,43 +415,119 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
181
415
|
};
|
|
182
416
|
}, [enableZoom]);
|
|
183
417
|
|
|
184
|
-
// Set up drag behavior
|
|
418
|
+
// Set up drag behavior with global listeners for smoother dragging
|
|
185
419
|
const handleDragStart = useCallback(
|
|
186
420
|
(event: React.MouseEvent, node: GraphNode) => {
|
|
187
421
|
if (!enableDrag) return;
|
|
422
|
+
event.preventDefault();
|
|
188
423
|
event.stopPropagation();
|
|
424
|
+
// pause forces while dragging to avoid the whole graph moving
|
|
425
|
+
dragActiveRef.current = true;
|
|
426
|
+
dragNodeRef.current = node;
|
|
189
427
|
node.fx = node.x;
|
|
190
428
|
node.fy = node.y;
|
|
191
|
-
|
|
429
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
430
|
+
try { stop(); } catch (e) {}
|
|
192
431
|
},
|
|
193
432
|
[enableDrag, restart]
|
|
194
433
|
);
|
|
195
434
|
|
|
196
|
-
|
|
197
|
-
(
|
|
198
|
-
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (!enableDrag) return;
|
|
437
|
+
|
|
438
|
+
const handleWindowMove = (event: MouseEvent) => {
|
|
439
|
+
if (!dragActiveRef.current || !dragNodeRef.current) return;
|
|
199
440
|
const svg = svgRef.current;
|
|
200
441
|
if (!svg) return;
|
|
201
|
-
|
|
202
442
|
const rect = svg.getBoundingClientRect();
|
|
203
443
|
const x = (event.clientX - rect.left - transform.x) / transform.k;
|
|
204
444
|
const y = (event.clientY - rect.top - transform.y) / transform.k;
|
|
445
|
+
dragNodeRef.current.fx = x;
|
|
446
|
+
dragNodeRef.current.fy = y;
|
|
447
|
+
};
|
|
205
448
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
449
|
+
const handleWindowUp = () => {
|
|
450
|
+
if (!dragActiveRef.current) return;
|
|
451
|
+
// Keep fx/fy set to pin the node where it was dropped.
|
|
452
|
+
try { setForcesEnabled(true); restart(); } catch (e) {}
|
|
453
|
+
dragNodeRef.current = null;
|
|
454
|
+
dragActiveRef.current = false;
|
|
455
|
+
};
|
|
211
456
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
457
|
+
const handleWindowLeave = (event: MouseEvent) => {
|
|
458
|
+
if (event.relatedTarget === null) handleWindowUp();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
window.addEventListener('mousemove', handleWindowMove);
|
|
462
|
+
window.addEventListener('mouseup', handleWindowUp);
|
|
463
|
+
window.addEventListener('mouseout', handleWindowLeave);
|
|
464
|
+
window.addEventListener('blur', handleWindowUp);
|
|
465
|
+
|
|
466
|
+
return () => {
|
|
467
|
+
window.removeEventListener('mousemove', handleWindowMove);
|
|
468
|
+
window.removeEventListener('mouseup', handleWindowUp);
|
|
469
|
+
window.removeEventListener('mouseout', handleWindowLeave);
|
|
470
|
+
window.removeEventListener('blur', handleWindowUp);
|
|
471
|
+
};
|
|
472
|
+
}, [enableDrag, transform]);
|
|
473
|
+
|
|
474
|
+
// Attach d3.drag behavior to node groups rendered by React. This helps make
|
|
475
|
+
// dragging more robust across transforms and pointer behaviors.
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (!gRef.current || !enableDrag) return;
|
|
478
|
+
const g = d3.select(gRef.current);
|
|
479
|
+
const dragBehavior = d3
|
|
480
|
+
.drag<SVGGElement, unknown>()
|
|
481
|
+
.on('start', function (event) {
|
|
482
|
+
try {
|
|
483
|
+
const target = (event.sourceEvent && (event.sourceEvent.target as Element)) || (event.target as Element);
|
|
484
|
+
const grp = target.closest?.('g.node') as Element | null;
|
|
485
|
+
const id = grp?.getAttribute('data-id');
|
|
486
|
+
if (!id) return;
|
|
487
|
+
const node = nodes.find((n) => n.id === id) as GraphNode | undefined;
|
|
488
|
+
if (!node) return;
|
|
489
|
+
if (!internalDragEnabledRef.current) return;
|
|
490
|
+
if (!event.active) restart();
|
|
491
|
+
dragActiveRef.current = true;
|
|
492
|
+
dragNodeRef.current = node;
|
|
493
|
+
node.fx = node.x;
|
|
494
|
+
node.fy = node.y;
|
|
495
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
496
|
+
} catch (e) {
|
|
497
|
+
// ignore
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
.on('drag', function (event) {
|
|
501
|
+
if (!dragActiveRef.current || !dragNodeRef.current) return;
|
|
502
|
+
const svg = svgRef.current;
|
|
503
|
+
if (!svg) return;
|
|
504
|
+
const rect = svg.getBoundingClientRect();
|
|
505
|
+
const x = (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
|
|
506
|
+
const y = (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
|
|
507
|
+
dragNodeRef.current.fx = x;
|
|
508
|
+
dragNodeRef.current.fy = y;
|
|
509
|
+
})
|
|
510
|
+
.on('end', function () {
|
|
511
|
+
// re-enable forces when drag ends
|
|
512
|
+
try { setForcesEnabled(true); restart(); } catch (e) {}
|
|
513
|
+
dragNodeRef.current = null;
|
|
514
|
+
dragActiveRef.current = false;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
g.selectAll('g.node').call(dragBehavior as any);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
// ignore attach errors
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return () => {
|
|
524
|
+
try {
|
|
525
|
+
g.selectAll('g.node').on('.drag', null as any);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
/* ignore */
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}, [gRef, enableDrag, nodes, transform, restart]);
|
|
221
531
|
|
|
222
532
|
const handleNodeClick = useCallback(
|
|
223
533
|
(node: GraphNode) => {
|
|
@@ -226,6 +536,37 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
226
536
|
[onNodeClick]
|
|
227
537
|
);
|
|
228
538
|
|
|
539
|
+
const handleNodeDoubleClick = useCallback(
|
|
540
|
+
(event: React.MouseEvent, node: GraphNode) => {
|
|
541
|
+
event.stopPropagation();
|
|
542
|
+
if (!enableDrag) return;
|
|
543
|
+
if (node.fx === null || node.fx === undefined) {
|
|
544
|
+
node.fx = node.x;
|
|
545
|
+
node.fy = node.y;
|
|
546
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
547
|
+
} else {
|
|
548
|
+
node.fx = null;
|
|
549
|
+
node.fy = null;
|
|
550
|
+
setPinnedNodes((prev) => {
|
|
551
|
+
const next = new Set(prev);
|
|
552
|
+
next.delete(node.id);
|
|
553
|
+
return next;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
restart();
|
|
557
|
+
},
|
|
558
|
+
[enableDrag, restart]
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const handleCanvasDoubleClick = useCallback(() => {
|
|
562
|
+
nodes.forEach((node) => {
|
|
563
|
+
node.fx = null;
|
|
564
|
+
node.fy = null;
|
|
565
|
+
});
|
|
566
|
+
setPinnedNodes(new Set());
|
|
567
|
+
restart();
|
|
568
|
+
}, [nodes, restart]);
|
|
569
|
+
|
|
229
570
|
const handleNodeMouseEnter = useCallback(
|
|
230
571
|
(node: GraphNode) => {
|
|
231
572
|
onNodeHover?.(node);
|
|
@@ -250,6 +591,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
250
591
|
width={width}
|
|
251
592
|
height={height}
|
|
252
593
|
className={cn('bg-white dark:bg-gray-900', className)}
|
|
594
|
+
onDoubleClick={handleCanvasDoubleClick}
|
|
253
595
|
>
|
|
254
596
|
<defs>
|
|
255
597
|
{/* Arrow marker for directed graphs */}
|
|
@@ -267,11 +609,12 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
267
609
|
</defs>
|
|
268
610
|
|
|
269
611
|
<g ref={gRef}>
|
|
612
|
+
|
|
270
613
|
{/* Render links */}
|
|
271
614
|
{links.map((link, i) => {
|
|
272
615
|
const source = link.source as GraphNode;
|
|
273
616
|
const target = link.target as GraphNode;
|
|
274
|
-
if (
|
|
617
|
+
if (source.x == null || source.y == null || target.x == null || target.y == null) return null;
|
|
275
618
|
|
|
276
619
|
return (
|
|
277
620
|
<g key={`link-${i}`}>
|
|
@@ -305,7 +648,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
305
648
|
|
|
306
649
|
{/* Render nodes */}
|
|
307
650
|
{nodes.map((node) => {
|
|
308
|
-
if (
|
|
651
|
+
if (node.x == null || node.y == null) return null;
|
|
309
652
|
|
|
310
653
|
const isSelected = selectedNodeId === node.id;
|
|
311
654
|
const isHovered = hoveredNodeId === node.id;
|
|
@@ -314,24 +657,34 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
314
657
|
|
|
315
658
|
return (
|
|
316
659
|
<g
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
660
|
+
key={node.id}
|
|
661
|
+
transform={`translate(${node.x},${node.y})`}
|
|
662
|
+
className="cursor-pointer node"
|
|
663
|
+
data-id={node.id}
|
|
664
|
+
onClick={() => handleNodeClick(node)}
|
|
665
|
+
onDoubleClick={(event) => handleNodeDoubleClick(event, node)}
|
|
666
|
+
onMouseEnter={() => handleNodeMouseEnter(node)}
|
|
667
|
+
onMouseLeave={handleNodeMouseLeave}
|
|
668
|
+
onMouseDown={(e) => handleDragStart(e, node)}
|
|
669
|
+
>
|
|
327
670
|
<circle
|
|
328
671
|
r={nodeSize}
|
|
329
672
|
fill={nodeColor}
|
|
330
673
|
stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
|
|
331
|
-
strokeWidth={
|
|
674
|
+
strokeWidth={pinnedNodes.has(node.id) ? 3 : isSelected ? 2.5 : isHovered ? 2 : 1.5}
|
|
332
675
|
opacity={isHovered || isSelected ? 1 : 0.9}
|
|
333
676
|
className="transition-all"
|
|
334
677
|
/>
|
|
678
|
+
{pinnedNodes.has(node.id) && (
|
|
679
|
+
<circle
|
|
680
|
+
r={nodeSize + 4}
|
|
681
|
+
fill="none"
|
|
682
|
+
stroke="#ff6b6b"
|
|
683
|
+
strokeWidth={1}
|
|
684
|
+
opacity={0.5}
|
|
685
|
+
className="pointer-events-none"
|
|
686
|
+
/>
|
|
687
|
+
)}
|
|
335
688
|
{showNodeLabels && node.label && (
|
|
336
689
|
<text
|
|
337
690
|
y={nodeSize + 15}
|
|
@@ -348,9 +701,39 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
|
|
|
348
701
|
</g>
|
|
349
702
|
);
|
|
350
703
|
})}
|
|
704
|
+
{/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
|
|
705
|
+
{packageBounds && Object.keys(packageBounds).length > 0 && (
|
|
706
|
+
<g className="package-boundaries" pointerEvents="none">
|
|
707
|
+
{Object.entries(packageBounds).map(([pid, b]) => (
|
|
708
|
+
<g key={pid}>
|
|
709
|
+
<circle
|
|
710
|
+
cx={b.x}
|
|
711
|
+
cy={b.y}
|
|
712
|
+
r={b.r}
|
|
713
|
+
fill="rgba(148,163,184,0.06)"
|
|
714
|
+
stroke="#475569"
|
|
715
|
+
strokeWidth={2}
|
|
716
|
+
strokeDasharray="6 6"
|
|
717
|
+
opacity={0.9}
|
|
718
|
+
/>
|
|
719
|
+
<text
|
|
720
|
+
x={b.x}
|
|
721
|
+
y={Math.max(12, b.y - b.r + 14)}
|
|
722
|
+
fill="#475569"
|
|
723
|
+
fontSize={11}
|
|
724
|
+
textAnchor="middle"
|
|
725
|
+
pointerEvents="none"
|
|
726
|
+
>
|
|
727
|
+
{pid.replace(/^pkg:/, '')}
|
|
728
|
+
</text>
|
|
729
|
+
</g>
|
|
730
|
+
))}
|
|
731
|
+
</g>
|
|
732
|
+
)}
|
|
351
733
|
</g>
|
|
352
734
|
</svg>
|
|
353
735
|
);
|
|
354
|
-
}
|
|
736
|
+
}
|
|
737
|
+
);
|
|
355
738
|
|
|
356
739
|
ForceDirectedGraph.displayName = 'ForceDirectedGraph';
|