@aiready/components 0.13.6 → 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.
- package/dist/charts/ForceDirectedGraph.js +265 -281
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/index.d.ts +32 -77
- package/dist/index.js +428 -637
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/ForceDirectedGraph.tsx +192 -393
- package/src/charts/PackageBoundaries.tsx +55 -0
- package/src/charts/constants.ts +30 -0
- package/src/charts/hooks.ts +87 -0
- package/src/charts/layout-utils.ts +93 -0
- package/src/components/icons/IconBase.tsx +38 -0
- package/src/components/icons/index.tsx +367 -0
- package/src/components/icons.tsx +3 -564
|
@@ -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 =
|
|
80
|
-
defaultNodeSize =
|
|
81
|
-
defaultLinkColor =
|
|
82
|
-
defaultLinkWidth =
|
|
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
|
-
//
|
|
180
|
+
// Initial positioning - delegate to layout utils
|
|
126
181
|
const nodes = React.useMemo(() => {
|
|
127
182
|
if (!initialNodes || !initialNodes.length) return initialNodes;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
171
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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 ||
|
|
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 -
|
|
317
|
-
(height -
|
|
261
|
+
(width - FIT_VIEW_PADDING * 2) / (maxX - minX),
|
|
262
|
+
(height - FIT_VIEW_PADDING * 2) / (maxY - minY),
|
|
318
263
|
10
|
|
319
264
|
);
|
|
320
|
-
|
|
321
|
-
const
|
|
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(
|
|
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
|
-
[
|
|
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
|
|
296
|
+
// Notify parent when manual layout mode changes
|
|
354
297
|
useEffect(() => {
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
437
|
-
stop();
|
|
438
|
-
} catch (e) {
|
|
439
|
-
void e;
|
|
440
|
-
}
|
|
358
|
+
stop();
|
|
441
359
|
},
|
|
442
|
-
[enableDrag,
|
|
360
|
+
[enableDrag, stop]
|
|
443
361
|
);
|
|
444
362
|
|
|
445
|
-
|
|
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',
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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',
|
|
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
|
-
|
|
390
|
+
dragNodeRef.current.fx =
|
|
526
391
|
(event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
|
|
527
|
-
|
|
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',
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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={
|
|
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
|
-
{
|
|
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={
|
|
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
|
|
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={
|
|
487
|
+
onClick={onNodeClick}
|
|
661
488
|
onDoubleClick={handleNodeDoubleClick}
|
|
662
|
-
onMouseEnter={
|
|
663
|
-
onMouseLeave={
|
|
489
|
+
onMouseEnter={(n) => onNodeHover?.(n)}
|
|
490
|
+
onMouseLeave={() => onNodeHover?.(null)}
|
|
664
491
|
onMouseDown={handleDragStart}
|
|
665
492
|
/>
|
|
666
493
|
))}
|
|
667
|
-
{
|
|
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
|
);
|