@aiready/components 0.13.5 → 0.13.7

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.
@@ -10,55 +10,110 @@ import * as d3 from 'd3';
10
10
  import { cn } from '../utils/cn';
11
11
  import NodeItem from './NodeItem';
12
12
  import LinkItem from './LinkItem';
13
+ import { PackageBoundaries } from './PackageBoundaries';
14
+ import {
15
+ applyCircularLayout,
16
+ applyHierarchicalLayout,
17
+ applyInitialForceLayout,
18
+ } from './layout-utils';
19
+ import {
20
+ DEFAULT_NODE_COLOR,
21
+ DEFAULT_NODE_SIZE,
22
+ DEFAULT_LINK_COLOR,
23
+ DEFAULT_LINK_WIDTH,
24
+ FIT_VIEW_PADDING,
25
+ TRANSITION_DURATION_MS,
26
+ } from './constants';
27
+ import { useGraphZoom, useWindowDrag } from './hooks';
13
28
 
14
29
  import { GraphNode, GraphLink, LayoutType } from './types';
15
30
  export type { GraphNode, GraphLink, LayoutType };
16
31
 
32
+ /**
33
+ * Handle for imperative actions on the ForceDirectedGraph.
34
+ */
17
35
  export interface ForceDirectedGraphHandle {
36
+ /** Pins all nodes to their current positions. */
18
37
  pinAll: () => void;
38
+ /** Unpins all nodes, allowing them to move freely in the simulation. */
19
39
  unpinAll: () => void;
40
+ /** Resets the layout by unpinning all nodes and restarting the simulation. */
20
41
  resetLayout: () => void;
42
+ /** Rescales and re-centers the view to fit all nodes. */
21
43
  fitView: () => void;
44
+ /** Returns the IDs of all currently pinned nodes. */
22
45
  getPinnedNodes: () => string[];
23
46
  /**
24
47
  * Enable or disable drag mode for nodes.
25
48
  * @param enabled - When true, nodes can be dragged; when false, dragging is disabled
26
49
  */
27
50
  setDragMode: (enabled: boolean) => void;
51
+ /** Sets the current layout type. */
28
52
  setLayout: (layout: LayoutType) => void;
53
+ /** Gets the current layout type. */
29
54
  getLayout: () => LayoutType;
30
55
  }
31
56
 
57
+ /**
58
+ * Props for the ForceDirectedGraph component.
59
+ */
32
60
  export interface ForceDirectedGraphProps {
61
+ /** Array of node objects to render. */
33
62
  nodes: GraphNode[];
63
+ /** Array of link objects to render. */
34
64
  links: GraphLink[];
65
+ /** Width of the SVG canvas. */
35
66
  width: number;
67
+ /** Height of the SVG canvas. */
36
68
  height: number;
69
+ /** Whether to enable zoom and pan interactions. */
37
70
  enableZoom?: boolean;
71
+ /** Whether to enable node dragging. */
38
72
  enableDrag?: boolean;
73
+ /** Callback fired when a node is clicked. */
39
74
  onNodeClick?: (node: GraphNode) => void;
75
+ /** Callback fired when a node is hovered. */
40
76
  onNodeHover?: (node: GraphNode | null) => void;
77
+ /** Callback fired when a link is clicked. */
41
78
  onLinkClick?: (link: GraphLink) => void;
79
+ /** ID of the currently selected node. */
42
80
  selectedNodeId?: string;
81
+ /** ID of the currently hovered node. */
43
82
  hoveredNodeId?: string;
83
+ /** Default fallback color for nodes. */
44
84
  defaultNodeColor?: string;
85
+ /** Default fallback size for nodes. */
45
86
  defaultNodeSize?: number;
87
+ /** Default fallback color for links. */
46
88
  defaultLinkColor?: string;
89
+ /** Default fallback width for links. */
47
90
  defaultLinkWidth?: number;
91
+ /** Whether to show labels on nodes. */
48
92
  showNodeLabels?: boolean;
93
+ /** Whether to show labels on links. */
49
94
  showLinkLabels?: boolean;
95
+ /** Additional CSS classes for the SVG element. */
50
96
  className?: string;
97
+ /** Whether manual layout mode is active. */
51
98
  manualLayout?: boolean;
52
- /**
53
- * Callback fired when manual layout mode changes.
54
- * @param enabled - True when manual layout mode is enabled, false when disabled
55
- */
99
+ /** Callback fired when manual layout mode changes. */
56
100
  onManualLayoutChange?: (enabled: boolean) => void;
101
+ /** Optional bounds for package groups. */
57
102
  packageBounds?: Record<string, { x: number; y: number; r: number }>;
103
+ /** Current layout algorithm. */
58
104
  layout?: LayoutType;
105
+ /** Callback fired when layout changes. */
59
106
  onLayoutChange?: (layout: LayoutType) => void;
60
107
  }
61
108
 
109
+ /**
110
+ * An interactive Force-Directed Graph component using D3.js for physics and React for rendering.
111
+ *
112
+ * Supports multiple layout modes (force, circular, hierarchical), pinning, zooming, and dragging.
113
+ * Optimal for visualizing complex dependency networks and codebase structures.
114
+ *
115
+ * @lastUpdated 2026-03-18
116
+ */
62
117
  export const ForceDirectedGraph = forwardRef<
63
118
  ForceDirectedGraphHandle,
64
119
  ForceDirectedGraphProps
@@ -76,10 +131,10 @@ export const ForceDirectedGraph = forwardRef<
76
131
  onLinkClick,
77
132
  selectedNodeId,
78
133
  hoveredNodeId,
79
- defaultNodeColor = '#69b3a2',
80
- defaultNodeSize = 10,
81
- defaultLinkColor = '#999',
82
- defaultLinkWidth = 1,
134
+ defaultNodeColor = DEFAULT_NODE_COLOR,
135
+ defaultNodeSize = DEFAULT_NODE_SIZE,
136
+ defaultLinkColor = DEFAULT_LINK_COLOR,
137
+ defaultLinkWidth = DEFAULT_LINK_WIDTH,
83
138
  showNodeLabels = true,
84
139
  showLinkLabels = false,
85
140
  className,
@@ -106,7 +161,7 @@ export const ForceDirectedGraph = forwardRef<
106
161
  if (externalLayout && externalLayout !== layout) {
107
162
  setLayout(externalLayout);
108
163
  }
109
- }, [externalLayout]);
164
+ }, [externalLayout, layout]);
110
165
 
111
166
  // Handle layout change and notify parent
112
167
  const handleLayoutChange = useCallback(
@@ -122,138 +177,38 @@ export const ForceDirectedGraph = forwardRef<
122
177
  internalDragEnabledRef.current = enableDrag;
123
178
  }, [enableDrag]);
124
179
 
125
- // Static layout - compute positions directly without force simulation
180
+ // Initial positioning - delegate to layout utils
126
181
  const nodes = React.useMemo(() => {
127
182
  if (!initialNodes || !initialNodes.length) return initialNodes;
128
-
129
- const centerX = width / 2;
130
- const centerY = height / 2;
131
-
132
- // For force layout, use random positions but don't animate
133
- if (layout === 'force') {
134
- return initialNodes.map((n: any) => ({
135
- ...n,
136
- x: Math.random() * width,
137
- y: Math.random() * height,
138
- }));
139
- }
140
-
141
- // For circular layout, arrange in a circle
142
- if (layout === 'circular') {
143
- const radius = Math.min(width, height) * 0.35;
144
- return initialNodes.map((n: any, i: number) => ({
145
- ...n,
146
- x:
147
- centerX +
148
- Math.cos((2 * Math.PI * i) / initialNodes.length) * radius,
149
- y:
150
- centerY +
151
- Math.sin((2 * Math.PI * i) / initialNodes.length) * radius,
152
- }));
153
- }
154
-
155
- // For hierarchical layout, arrange in a grid
156
- if (layout === 'hierarchical') {
157
- const cols = Math.ceil(Math.sqrt(initialNodes.length));
158
- const spacingX = width / (cols + 1);
159
- const spacingY = height / (Math.ceil(initialNodes.length / cols) + 1);
160
- return initialNodes.map((n: any, i: number) => ({
161
- ...n,
162
- x: spacingX * ((i % cols) + 1),
163
- y: spacingY * (Math.floor(i / cols) + 1),
164
- }));
165
- }
166
-
167
- return initialNodes;
183
+ const copy = initialNodes.map((n) => ({ ...n }));
184
+ if (layout === 'circular') applyCircularLayout(copy, width, height);
185
+ else if (layout === 'hierarchical')
186
+ applyHierarchicalLayout(copy, width, height);
187
+ else applyInitialForceLayout(copy, width, height);
188
+ return copy;
168
189
  }, [initialNodes, width, height, layout]);
169
190
 
170
- // Static links - just use initial links
171
- const links = initialLinks;
172
-
173
- // No force simulation - static layout only
174
- const restart = React.useCallback(() => {
175
- // No-op for static layout
176
- }, []);
177
-
178
- const stop = React.useCallback(() => {
179
- // No-op for static layout
180
- }, []);
181
-
191
+ // No force simulation - static layout only (stubs for API compatibility)
192
+ const restart = React.useCallback(() => {}, []);
193
+ const stop = React.useCallback(() => {}, []);
182
194
  const setForcesEnabled = React.useCallback((enabled?: boolean) => {
183
- // No-op for static layout; accept optional `enabled` arg for API compatibility
184
195
  void enabled;
185
196
  }, []);
186
197
 
187
- // Remove package bounds effect - boundary packing disabled for faster convergence
188
-
189
198
  // Apply layout-specific positioning when layout changes
190
199
  useEffect(() => {
191
200
  if (!nodes || nodes.length === 0) return;
201
+ if (layout === 'circular') applyCircularLayout(nodes, width, height);
202
+ else if (layout === 'hierarchical')
203
+ applyHierarchicalLayout(nodes, width, height);
192
204
 
193
- const applyLayout = () => {
194
- const centerX = width / 2;
195
- const centerY = height / 2;
196
-
197
- if (layout === 'circular') {
198
- // Place all nodes in a circle
199
- const radius = Math.min(width, height) * 0.35;
200
- nodes.forEach((node, i) => {
201
- const angle = (2 * Math.PI * i) / nodes.length;
202
- node.fx = centerX + Math.cos(angle) * radius;
203
- node.fy = centerY + Math.sin(angle) * radius;
204
- });
205
- } else if (layout === 'hierarchical') {
206
- // Place packages in rows, files within packages in columns
207
- const groups = new Map<string, typeof nodes>();
208
- nodes.forEach((n: any) => {
209
- const key = n.packageGroup || n.group || 'root';
210
- if (!groups.has(key)) groups.set(key, []);
211
- groups.get(key)!.push(n);
212
- });
213
-
214
- const groupArray = Array.from(groups.entries());
215
- const cols = Math.ceil(Math.sqrt(groupArray.length));
216
- const groupSpacingX = (width * 0.8) / cols;
217
- const groupSpacingY =
218
- (height * 0.8) / Math.ceil(groupArray.length / cols);
219
-
220
- groupArray.forEach(([groupKey, groupNodes], gi) => {
221
- const col = gi % cols;
222
- const row = Math.floor(gi / cols);
223
- const groupX = (col + 0.5) * groupSpacingX;
224
- const groupY = (row + 0.5) * groupSpacingY;
225
-
226
- // Place group nodes in a small circle within their area
227
- if (groupKey.startsWith('pkg:') || groupKey === groupKey) {
228
- groupNodes.forEach((n, ni) => {
229
- const angle = (2 * Math.PI * ni) / groupNodes.length;
230
- const r = Math.min(80, 20 + groupNodes.length * 8);
231
- n.fx = groupX + Math.cos(angle) * r;
232
- n.fy = groupY + Math.sin(angle) * r;
233
- });
234
- }
235
- });
236
- }
237
- // 'force' layout - just restart with default behavior (no fx/fy set)
238
-
239
- try {
240
- restart();
241
- } catch (e) {
242
- void e;
243
- }
244
- };
245
-
246
- applyLayout();
205
+ restart();
247
206
  }, [layout, nodes, width, height, restart]);
248
207
 
249
208
  // If manual layout is enabled or any nodes are pinned, disable forces
250
209
  useEffect(() => {
251
- try {
252
- if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
253
- else setForcesEnabled(true);
254
- } catch (e) {
255
- void e;
256
- }
210
+ if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
211
+ else setForcesEnabled(true);
257
212
  }, [manualLayout, pinnedNodes, setForcesEnabled]);
258
213
 
259
214
  // Expose imperative handle for parent components
@@ -270,7 +225,6 @@ export const ForceDirectedGraph = forwardRef<
270
225
  setPinnedNodes(newPinned);
271
226
  restart();
272
227
  },
273
-
274
228
  unpinAll: () => {
275
229
  nodes.forEach((node) => {
276
230
  node.fx = null;
@@ -279,7 +233,6 @@ export const ForceDirectedGraph = forwardRef<
279
233
  setPinnedNodes(new Set());
280
234
  restart();
281
235
  },
282
-
283
236
  resetLayout: () => {
284
237
  nodes.forEach((node) => {
285
238
  node.fx = null;
@@ -288,278 +241,175 @@ export const ForceDirectedGraph = forwardRef<
288
241
  setPinnedNodes(new Set());
289
242
  restart();
290
243
  },
291
-
292
244
  fitView: () => {
293
245
  if (!svgRef.current || !nodes.length) return;
294
-
295
- // Calculate bounds
296
246
  let minX = Infinity,
297
247
  maxX = -Infinity,
298
248
  minY = Infinity,
299
249
  maxY = -Infinity;
300
250
  nodes.forEach((node) => {
301
251
  if (node.x !== undefined && node.y !== undefined) {
302
- const size = node.size || 10;
252
+ const size = node.size || DEFAULT_NODE_SIZE;
303
253
  minX = Math.min(minX, node.x - size);
304
254
  maxX = Math.max(maxX, node.x + size);
305
255
  minY = Math.min(minY, node.y - size);
306
256
  maxY = Math.max(maxY, node.y + size);
307
257
  }
308
258
  });
309
-
310
259
  if (!isFinite(minX)) return;
311
-
312
- const padding = 40;
313
- const nodeWidth = maxX - minX;
314
- const nodeHeight = maxY - minY;
315
260
  const scale = Math.min(
316
- (width - padding * 2) / nodeWidth,
317
- (height - padding * 2) / nodeHeight,
261
+ (width - FIT_VIEW_PADDING * 2) / (maxX - minX),
262
+ (height - FIT_VIEW_PADDING * 2) / (maxY - minY),
318
263
  10
319
264
  );
320
-
321
- const centerX = (minX + maxX) / 2;
322
- const centerY = (minY + maxY) / 2;
323
-
324
- const x = width / 2 - centerX * scale;
325
- const y = height / 2 - centerY * scale;
326
-
265
+ const x = width / 2 - ((minX + maxX) / 2) * scale;
266
+ const y = height / 2 - ((minY + maxY) / 2) * scale;
327
267
  if (gRef.current && svgRef.current) {
328
268
  const svg = d3.select(svgRef.current);
329
269
  const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
330
270
  svg
331
271
  .transition()
332
- .duration(300)
272
+ .duration(TRANSITION_DURATION_MS)
333
273
  .call((d3 as any).zoom().transform as any, newTransform);
334
274
  setTransform(newTransform);
335
275
  }
336
276
  },
337
-
338
277
  getPinnedNodes: () => Array.from(pinnedNodes),
339
-
340
278
  setDragMode: (enabled: boolean) => {
341
279
  internalDragEnabledRef.current = enabled;
342
280
  },
343
-
344
- setLayout: (newLayout: LayoutType) => {
345
- handleLayoutChange(newLayout);
346
- },
347
-
281
+ setLayout: (newLayout: LayoutType) => handleLayoutChange(newLayout),
348
282
  getLayout: () => layout,
349
283
  }),
350
- [nodes, pinnedNodes, restart, width, height, layout, handleLayoutChange]
284
+ [
285
+ nodes,
286
+ pinnedNodes,
287
+ restart,
288
+ width,
289
+ height,
290
+ layout,
291
+ handleLayoutChange,
292
+ setForcesEnabled,
293
+ ]
351
294
  );
352
295
 
353
- // Notify parent when manual layout mode changes (uses the prop so it's not unused)
296
+ // Notify parent when manual layout mode changes
354
297
  useEffect(() => {
355
- try {
356
- if (typeof onManualLayoutChange === 'function')
357
- onManualLayoutChange(manualLayout);
358
- } catch (e) {
359
- void e;
360
- }
298
+ if (typeof onManualLayoutChange === 'function')
299
+ onManualLayoutChange(manualLayout);
361
300
  }, [manualLayout, onManualLayoutChange]);
362
301
 
363
- // Set up zoom behavior
364
- useEffect(() => {
365
- if (!enableZoom || !svgRef.current || !gRef.current) return;
366
-
367
- const svg = d3.select(svgRef.current);
368
- const g = d3.select(gRef.current);
369
-
370
- const zoom = (d3 as any)
371
- .zoom()
372
- .scaleExtent([0.1, 10])
373
- .on('zoom', (event: any) => {
374
- g.attr('transform', event.transform);
375
- transformRef.current = event.transform;
376
- setTransform(event.transform);
377
- });
378
-
379
- svg.call(zoom);
380
-
381
- return () => {
382
- svg.on('.zoom', null);
383
- };
384
- }, [enableZoom]);
302
+ // Use custom hooks for zoom and window-level drag
303
+ useGraphZoom(svgRef, gRef, enableZoom, setTransform, transformRef);
304
+ useWindowDrag(
305
+ enableDrag,
306
+ svgRef,
307
+ transformRef,
308
+ dragActiveRef,
309
+ dragNodeRef,
310
+ () => {
311
+ setForcesEnabled(true);
312
+ restart();
313
+ }
314
+ );
385
315
 
386
- // Run a one-time DOM positioning pass when nodes/links change so elements
387
- // rendered by React are positioned to the simulation's seeded coordinates
316
+ // Run positioning pass when nodes/links change
388
317
  useEffect(() => {
389
318
  if (!gRef.current) return;
390
- try {
391
- const g = d3.select(gRef.current);
392
- g.selectAll('g.node').each(function (this: any) {
393
- const datum = d3.select(this).datum() as any;
394
- if (!datum) return;
395
- d3.select(this).attr(
396
- 'transform',
397
- `translate(${datum.x || 0},${datum.y || 0})`
398
- );
399
- });
400
-
401
- g.selectAll('line').each(function (this: any) {
402
- const l = d3.select(this).datum() as any;
403
- if (!l) return;
404
- const s: any =
405
- typeof l.source === 'object'
406
- ? l.source
407
- : nodes.find((n) => n.id === l.source) || l.source;
408
- const t: any =
409
- typeof l.target === 'object'
410
- ? l.target
411
- : nodes.find((n) => n.id === l.target) || l.target;
412
- if (!s || !t) return;
413
- d3.select(this)
414
- .attr('x1', s.x)
415
- .attr('y1', s.y)
416
- .attr('x2', t.x)
417
- .attr('y2', t.y);
418
- });
419
- } catch (e) {
420
- void e;
421
- }
422
- }, [nodes, links]);
319
+ const g = d3.select(gRef.current);
320
+ g.selectAll('g.node').each(function (this: any) {
321
+ const datum = d3.select(this).datum() as any;
322
+ if (!datum) return;
323
+ d3.select(this).attr(
324
+ 'transform',
325
+ `translate(${datum.x || 0},${datum.y || 0})`
326
+ );
327
+ });
328
+ g.selectAll('line').each(function (this: any) {
329
+ const l = d3.select(this).datum() as any;
330
+ if (!l) return;
331
+ const s: any =
332
+ typeof l.source === 'object'
333
+ ? l.source
334
+ : nodes.find((n) => n.id === l.source) || l.source;
335
+ const t: any =
336
+ typeof l.target === 'object'
337
+ ? l.target
338
+ : nodes.find((n) => n.id === l.target) || l.target;
339
+ if (!s || !t) return;
340
+ d3.select(this)
341
+ .attr('x1', s.x)
342
+ .attr('y1', s.y)
343
+ .attr('x2', t.x)
344
+ .attr('y2', t.y);
345
+ });
346
+ }, [nodes, initialLinks]);
423
347
 
424
- // Set up drag behavior with global listeners for smoother dragging
425
348
  const handleDragStart = useCallback(
426
349
  (event: React.MouseEvent, node: GraphNode) => {
427
350
  if (!enableDrag) return;
428
351
  event.preventDefault();
429
352
  event.stopPropagation();
430
- // pause forces while dragging to avoid the whole graph moving
431
353
  dragActiveRef.current = true;
432
354
  dragNodeRef.current = node;
433
355
  node.fx = node.x;
434
356
  node.fy = node.y;
435
357
  setPinnedNodes((prev) => new Set([...prev, node.id]));
436
- try {
437
- stop();
438
- } catch (e) {
439
- void e;
440
- }
358
+ stop();
441
359
  },
442
- [enableDrag, restart]
360
+ [enableDrag, stop]
443
361
  );
444
362
 
445
- useEffect(() => {
446
- if (!enableDrag) return;
447
-
448
- const handleWindowMove = (event: MouseEvent) => {
449
- if (!dragActiveRef.current || !dragNodeRef.current) return;
450
- const svg = svgRef.current;
451
- if (!svg) return;
452
- const rect = svg.getBoundingClientRect();
453
- const t: any = transformRef.current;
454
- const x = (event.clientX - rect.left - t.x) / t.k;
455
- const y = (event.clientY - rect.top - t.y) / t.k;
456
- dragNodeRef.current.fx = x;
457
- dragNodeRef.current.fy = y;
458
- };
459
-
460
- const handleWindowUp = () => {
461
- if (!dragActiveRef.current) return;
462
- // Keep fx/fy set to pin the node where it was dropped.
463
- try {
464
- setForcesEnabled(true);
465
- restart();
466
- } catch (e) {
467
- void e;
468
- }
469
- dragNodeRef.current = null;
470
- dragActiveRef.current = false;
471
- };
472
-
473
- const handleWindowLeave = (event: MouseEvent) => {
474
- if (event.relatedTarget === null) handleWindowUp();
475
- };
476
-
477
- window.addEventListener('mousemove', handleWindowMove);
478
- window.addEventListener('mouseup', handleWindowUp);
479
- window.addEventListener('mouseout', handleWindowLeave);
480
- window.addEventListener('blur', handleWindowUp);
481
-
482
- return () => {
483
- window.removeEventListener('mousemove', handleWindowMove);
484
- window.removeEventListener('mouseup', handleWindowUp);
485
- window.removeEventListener('mouseout', handleWindowLeave);
486
- window.removeEventListener('blur', handleWindowUp);
487
- };
488
- }, [enableDrag]);
489
-
490
- // Attach d3.drag behavior to node groups rendered by React. This helps make
491
- // dragging more robust across transforms and pointer behaviors.
363
+ // Attach d3.drag behavior to nodes
492
364
  useEffect(() => {
493
365
  if (!gRef.current || !enableDrag) return;
494
366
  const g = d3.select(gRef.current);
495
367
  const dragBehavior = (d3 as any)
496
368
  .drag()
497
- .on('start', function (this: any, event: any) {
498
- try {
499
- const target =
500
- (event.sourceEvent && (event.sourceEvent.target as Element)) ||
501
- (event.target as Element);
502
- const grp = target.closest?.('g.node') as Element | null;
503
- const id = grp?.getAttribute('data-id');
504
- if (!id) return;
505
- const node = nodes.find((n) => n.id === id) as
506
- | GraphNode
507
- | undefined;
508
- if (!node) return;
509
- if (!internalDragEnabledRef.current) return;
510
- if (!event.active) restart();
511
- dragActiveRef.current = true;
512
- dragNodeRef.current = node;
513
- node.fx = node.x;
514
- node.fy = node.y;
515
- setPinnedNodes((prev) => new Set([...prev, node.id]));
516
- } catch (e) {
517
- void e;
518
- }
369
+ .on('start', (event: any) => {
370
+ const target =
371
+ (event.sourceEvent && (event.sourceEvent.target as Element)) ||
372
+ (event.target as Element);
373
+ const grp = target.closest?.('g.node') as Element | null;
374
+ const id = grp?.getAttribute('data-id');
375
+ if (!id || !internalDragEnabledRef.current) return;
376
+ const node = nodes.find((n) => n.id === id);
377
+ if (!node) return;
378
+ if (!event.active) restart();
379
+ dragActiveRef.current = true;
380
+ dragNodeRef.current = node;
381
+ node.fx = node.x;
382
+ node.fy = node.y;
383
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
519
384
  })
520
- .on('drag', function (this: any, event: any) {
385
+ .on('drag', (event: any) => {
521
386
  if (!dragActiveRef.current || !dragNodeRef.current) return;
522
387
  const svg = svgRef.current;
523
388
  if (!svg) return;
524
389
  const rect = svg.getBoundingClientRect();
525
- const x =
390
+ dragNodeRef.current.fx =
526
391
  (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
527
- const y =
392
+ dragNodeRef.current.fy =
528
393
  (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
529
- dragNodeRef.current.fx = x;
530
- dragNodeRef.current.fy = y;
531
394
  })
532
- .on('end', function () {
533
- // re-enable forces when drag ends
534
- try {
535
- setForcesEnabled(true);
536
- restart();
537
- } catch (e) {
538
- void e;
539
- }
395
+ .on('end', () => {
396
+ setForcesEnabled(true);
397
+ restart();
540
398
  });
541
399
 
542
- try {
543
- g.selectAll('g.node').call(dragBehavior as any);
544
- } catch (e) {
545
- void e;
546
- }
547
-
400
+ g.selectAll('g.node').call(dragBehavior as any);
548
401
  return () => {
549
- try {
550
- g.selectAll('g.node').on('.drag', null as any);
551
- } catch (e) {
552
- void e;
553
- }
402
+ g.selectAll('g.node').on('.drag', null as any);
554
403
  };
555
- }, [gRef, enableDrag, nodes, transform, restart]);
556
-
557
- const handleNodeClick = useCallback(
558
- (node: GraphNode) => {
559
- onNodeClick?.(node);
560
- },
561
- [onNodeClick]
562
- );
404
+ }, [
405
+ gRef,
406
+ enableDrag,
407
+ nodes,
408
+ transform,
409
+ restart,
410
+ setForcesEnabled,
411
+ internalDragEnabledRef,
412
+ ]);
563
413
 
564
414
  const handleNodeDoubleClick = useCallback(
565
415
  (event: React.MouseEvent, node: GraphNode) => {
@@ -583,43 +433,22 @@ export const ForceDirectedGraph = forwardRef<
583
433
  [enableDrag, restart]
584
434
  );
585
435
 
586
- const handleCanvasDoubleClick = useCallback(() => {
587
- nodes.forEach((node) => {
588
- node.fx = null;
589
- node.fy = null;
590
- });
591
- setPinnedNodes(new Set());
592
- restart();
593
- }, [nodes, restart]);
594
-
595
- const handleNodeMouseEnter = useCallback(
596
- (node: GraphNode) => {
597
- onNodeHover?.(node);
598
- },
599
- [onNodeHover]
600
- );
601
-
602
- const handleNodeMouseLeave = useCallback(() => {
603
- onNodeHover?.(null);
604
- }, [onNodeHover]);
605
-
606
- const handleLinkClick = useCallback(
607
- (link: GraphLink) => {
608
- onLinkClick?.(link);
609
- },
610
- [onLinkClick]
611
- );
612
-
613
436
  return (
614
437
  <svg
615
438
  ref={svgRef}
616
439
  width={width}
617
440
  height={height}
618
441
  className={cn('bg-white dark:bg-gray-900', className)}
619
- onDoubleClick={handleCanvasDoubleClick}
442
+ onDoubleClick={() => {
443
+ nodes.forEach((n) => {
444
+ n.fx = null;
445
+ n.fy = null;
446
+ });
447
+ setPinnedNodes(new Set());
448
+ restart();
449
+ }}
620
450
  >
621
451
  <defs>
622
- {/* Arrow marker for directed graphs */}
623
452
  <marker
624
453
  id="arrow"
625
454
  viewBox="0 0 10 10"
@@ -634,65 +463,35 @@ export const ForceDirectedGraph = forwardRef<
634
463
  </defs>
635
464
 
636
465
  <g ref={gRef}>
637
- {/* Render links via LinkItem (positions updated by D3) */}
638
- {links.map((link, i) => (
466
+ {initialLinks.map((link, i) => (
639
467
  <LinkItem
640
468
  key={`link-${i}`}
641
469
  link={link as GraphLink}
642
- onClick={handleLinkClick}
470
+ onClick={onLinkClick}
643
471
  defaultWidth={defaultLinkWidth}
644
472
  showLabel={showLinkLabels}
645
473
  nodes={nodes}
646
474
  />
647
475
  ))}
648
476
 
649
- {/* Render nodes via NodeItem (D3 will set transforms) */}
650
477
  {nodes.map((node) => (
651
478
  <NodeItem
652
479
  key={node.id}
653
- node={node as GraphNode}
480
+ node={node}
654
481
  isSelected={selectedNodeId === node.id}
655
482
  isHovered={hoveredNodeId === node.id}
656
483
  pinned={pinnedNodes.has(node.id)}
657
484
  defaultNodeSize={defaultNodeSize}
658
485
  defaultNodeColor={defaultNodeColor}
659
486
  showLabel={showNodeLabels}
660
- onClick={handleNodeClick}
487
+ onClick={onNodeClick}
661
488
  onDoubleClick={handleNodeDoubleClick}
662
- onMouseEnter={handleNodeMouseEnter}
663
- onMouseLeave={handleNodeMouseLeave}
489
+ onMouseEnter={(n) => onNodeHover?.(n)}
490
+ onMouseLeave={() => onNodeHover?.(null)}
664
491
  onMouseDown={handleDragStart}
665
492
  />
666
493
  ))}
667
- {/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
668
- {packageBounds && Object.keys(packageBounds).length > 0 && (
669
- <g className="package-boundaries" pointerEvents="none">
670
- {Object.entries(packageBounds).map(([pid, b]) => (
671
- <g key={pid}>
672
- <circle
673
- cx={b.x}
674
- cy={b.y}
675
- r={b.r}
676
- fill="rgba(148,163,184,0.06)"
677
- stroke="#475569"
678
- strokeWidth={2}
679
- strokeDasharray="6 6"
680
- opacity={0.9}
681
- />
682
- <text
683
- x={b.x}
684
- y={Math.max(12, b.y - b.r + 14)}
685
- fill="#475569"
686
- fontSize={11}
687
- textAnchor="middle"
688
- pointerEvents="none"
689
- >
690
- {pid.replace(/^pkg:/, '')}
691
- </text>
692
- </g>
693
- ))}
694
- </g>
695
- )}
494
+ <PackageBoundaries packageBounds={packageBounds || {}} />
696
495
  </g>
697
496
  </svg>
698
497
  );