@aiready/components 0.1.30 → 0.1.32
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/README.md +2 -2
- package/dist/charts/ForceDirectedGraph.js +49 -13
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/badge.js.map +1 -1
- package/dist/components/button.js.map +1 -1
- package/dist/components/card.js.map +1 -1
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/container.js.map +1 -1
- package/dist/components/grid.js.map +1 -1
- package/dist/components/input.d.ts +1 -2
- package/dist/components/input.js.map +1 -1
- package/dist/components/label.js +1 -8
- package/dist/components/label.js.map +1 -1
- package/dist/components/radio-group.js.map +1 -1
- package/dist/components/select.js.map +1 -1
- package/dist/components/separator.js.map +1 -1
- package/dist/components/stack.js.map +1 -1
- package/dist/components/switch.js +29 -22
- package/dist/components/switch.js.map +1 -1
- package/dist/components/textarea.d.ts +1 -2
- package/dist/components/textarea.js.map +1 -1
- package/dist/hooks/useD3.js.map +1 -1
- package/dist/hooks/useDebounce.js.map +1 -1
- package/dist/hooks/useForceSimulation.d.ts +1 -0
- package/dist/hooks/useForceSimulation.js +37 -14
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +337 -141
- package/dist/index.js.map +1 -1
- package/dist/utils/cn.js.map +1 -1
- package/dist/utils/colors.js.map +1 -1
- package/dist/utils/formatters.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/smoke.test.js +1 -1
- package/src/__tests__/smoke.test.ts +3 -3
- package/src/charts/ForceDirectedGraph.tsx +583 -517
- package/src/charts/GraphControls.tsx +5 -2
- package/src/charts/LinkItem.tsx +17 -5
- package/src/charts/NodeItem.tsx +17 -2
- package/src/code-block/CodeBlock.tsx +53 -16
- package/src/code-block/index.ts +1 -1
- package/src/components/badge.tsx +3 -2
- package/src/components/button.tsx +3 -2
- package/src/components/card.tsx +8 -1
- package/src/components/checkbox.tsx +6 -4
- package/src/components/container.tsx +1 -1
- package/src/components/grid.tsx +1 -1
- package/src/components/input.tsx +2 -3
- package/src/components/label.tsx +4 -7
- package/src/components/radio-group.tsx +5 -3
- package/src/components/select.tsx +5 -3
- package/src/components/separator.tsx +1 -1
- package/src/components/stack.tsx +1 -1
- package/src/components/switch.tsx +15 -7
- package/src/components/textarea.tsx +2 -3
- package/src/data-display/ScoreBar.tsx +52 -15
- package/src/data-display/index.ts +7 -1
- package/src/feedback/ErrorDisplay.tsx +17 -4
- package/src/feedback/LoadingSpinner.tsx +8 -3
- package/src/feedback/index.ts +12 -2
- package/src/hooks/useD3.ts +1 -3
- package/src/hooks/useDebounce.ts +1 -1
- package/src/hooks/useForceSimulation.ts +142 -44
- package/src/index.ts +29 -9
- package/src/navigation/Breadcrumb.tsx +17 -8
- package/src/navigation/index.ts +5 -1
- package/src/theme/ThemeProvider.tsx +11 -3
- package/src/theme/index.ts +6 -1
- package/src/utils/cn.ts +1 -1
- package/src/utils/colors.ts +1 -1
- package/src/utils/formatters.ts +1 -1
- package/src/utils/score.ts +3 -1
- package/tailwind.config.js +1 -1
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
forwardRef,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import * as d3 from 'd3';
|
|
3
10
|
import { cn } from '../utils/cn';
|
|
4
11
|
import NodeItem from './NodeItem';
|
|
@@ -66,7 +73,10 @@ export interface ForceDirectedGraphProps {
|
|
|
66
73
|
onLayoutChange?: (layout: LayoutType) => void;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
export const ForceDirectedGraph = forwardRef<
|
|
76
|
+
export const ForceDirectedGraph = forwardRef<
|
|
77
|
+
ForceDirectedGraphHandle,
|
|
78
|
+
ForceDirectedGraphProps
|
|
79
|
+
>(
|
|
70
80
|
(
|
|
71
81
|
{
|
|
72
82
|
nodes: initialNodes,
|
|
@@ -95,563 +105,619 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
95
105
|
},
|
|
96
106
|
ref
|
|
97
107
|
) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// For force layout, use random positions but don't animate
|
|
135
|
-
if (layout === 'force') {
|
|
136
|
-
return initialNodes.map((n: any) => ({
|
|
137
|
-
...n,
|
|
138
|
-
x: Math.random() * width,
|
|
139
|
-
y: Math.random() * height,
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// For circular layout, arrange in a circle
|
|
144
|
-
if (layout === 'circular') {
|
|
145
|
-
const radius = Math.min(width, height) * 0.35;
|
|
146
|
-
return initialNodes.map((n: any, i: number) => ({
|
|
147
|
-
...n,
|
|
148
|
-
x: centerX + Math.cos((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
149
|
-
y: centerY + Math.sin((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
150
|
-
}));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// For hierarchical layout, arrange in a grid
|
|
154
|
-
if (layout === 'hierarchical') {
|
|
155
|
-
const cols = Math.ceil(Math.sqrt(initialNodes.length));
|
|
156
|
-
const spacingX = width / (cols + 1);
|
|
157
|
-
const spacingY = height / (Math.ceil(initialNodes.length / cols) + 1);
|
|
158
|
-
return initialNodes.map((n: any, i: number) => ({
|
|
159
|
-
...n,
|
|
160
|
-
x: spacingX * ((i % cols) + 1),
|
|
161
|
-
y: spacingY * (Math.floor(i / cols) + 1),
|
|
162
|
-
}));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return initialNodes;
|
|
166
|
-
}, [initialNodes, width, height, layout]);
|
|
167
|
-
|
|
168
|
-
// Static links - just use initial links
|
|
169
|
-
const links = initialLinks;
|
|
170
|
-
|
|
171
|
-
// No force simulation - static layout only
|
|
172
|
-
const restart = React.useCallback(() => {
|
|
173
|
-
// No-op for static layout
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
const stop = React.useCallback(() => {
|
|
177
|
-
// No-op for static layout
|
|
178
|
-
}, []);
|
|
179
|
-
|
|
180
|
-
const setForcesEnabled = React.useCallback((_enabled: boolean) => {
|
|
181
|
-
// No-op for static layout
|
|
182
|
-
}, []);
|
|
183
|
-
|
|
184
|
-
// Remove package bounds effect - boundary packing disabled for faster convergence
|
|
185
|
-
|
|
186
|
-
// Apply layout-specific positioning when layout changes
|
|
187
|
-
useEffect(() => {
|
|
188
|
-
if (!nodes || nodes.length === 0) return;
|
|
189
|
-
|
|
190
|
-
const applyLayout = () => {
|
|
108
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
109
|
+
const gRef = useRef<SVGGElement>(null);
|
|
110
|
+
const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
|
|
111
|
+
const transformRef = useRef(transform);
|
|
112
|
+
const dragNodeRef = useRef<GraphNode | null>(null);
|
|
113
|
+
const dragActiveRef = useRef(false);
|
|
114
|
+
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
|
|
115
|
+
const internalDragEnabledRef = useRef(enableDrag);
|
|
116
|
+
const [layout, setLayout] = useState<LayoutType>(externalLayout || 'force');
|
|
117
|
+
|
|
118
|
+
// Sync external layout prop with internal state
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (externalLayout && externalLayout !== layout) {
|
|
121
|
+
setLayout(externalLayout);
|
|
122
|
+
}
|
|
123
|
+
}, [externalLayout]);
|
|
124
|
+
|
|
125
|
+
// Handle layout change and notify parent
|
|
126
|
+
const handleLayoutChange = useCallback(
|
|
127
|
+
(newLayout: LayoutType) => {
|
|
128
|
+
setLayout(newLayout);
|
|
129
|
+
onLayoutChange?.(newLayout);
|
|
130
|
+
},
|
|
131
|
+
[onLayoutChange]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Update the ref when enableDrag prop changes
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
internalDragEnabledRef.current = enableDrag;
|
|
137
|
+
}, [enableDrag]);
|
|
138
|
+
|
|
139
|
+
// Static layout - compute positions directly without force simulation
|
|
140
|
+
const nodes = React.useMemo(() => {
|
|
141
|
+
if (!initialNodes || !initialNodes.length) return initialNodes;
|
|
142
|
+
|
|
191
143
|
const centerX = width / 2;
|
|
192
144
|
const centerY = height / 2;
|
|
193
|
-
|
|
145
|
+
|
|
146
|
+
// For force layout, use random positions but don't animate
|
|
147
|
+
if (layout === 'force') {
|
|
148
|
+
return initialNodes.map((n: any) => ({
|
|
149
|
+
...n,
|
|
150
|
+
x: Math.random() * width,
|
|
151
|
+
y: Math.random() * height,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// For circular layout, arrange in a circle
|
|
194
156
|
if (layout === 'circular') {
|
|
195
|
-
// Place all nodes in a circle
|
|
196
157
|
const radius = Math.min(width, height) * 0.35;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const key = n.packageGroup || n.group || 'root';
|
|
207
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
208
|
-
groups.get(key)!.push(n);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
const groupArray = Array.from(groups.entries());
|
|
212
|
-
const cols = Math.ceil(Math.sqrt(groupArray.length));
|
|
213
|
-
const groupSpacingX = width * 0.8 / cols;
|
|
214
|
-
const groupSpacingY = height * 0.8 / Math.ceil(groupArray.length / cols);
|
|
215
|
-
|
|
216
|
-
groupArray.forEach(([groupKey, groupNodes], gi) => {
|
|
217
|
-
const col = gi % cols;
|
|
218
|
-
const row = Math.floor(gi / cols);
|
|
219
|
-
const groupX = (col + 0.5) * groupSpacingX;
|
|
220
|
-
const groupY = (row + 0.5) * groupSpacingY;
|
|
221
|
-
|
|
222
|
-
// Place group nodes in a small circle within their area
|
|
223
|
-
if (groupKey.startsWith('pkg:') || groupKey === groupKey) {
|
|
224
|
-
groupNodes.forEach((n, ni) => {
|
|
225
|
-
const angle = (2 * Math.PI * ni) / groupNodes.length;
|
|
226
|
-
const r = Math.min(80, 20 + groupNodes.length * 8);
|
|
227
|
-
n.fx = groupX + Math.cos(angle) * r;
|
|
228
|
-
n.fy = groupY + Math.sin(angle) * r;
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
});
|
|
158
|
+
return initialNodes.map((n: any, i: number) => ({
|
|
159
|
+
...n,
|
|
160
|
+
x:
|
|
161
|
+
centerX +
|
|
162
|
+
Math.cos((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
163
|
+
y:
|
|
164
|
+
centerY +
|
|
165
|
+
Math.sin((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
166
|
+
}));
|
|
232
167
|
}
|
|
233
|
-
// 'force' layout - just restart with default behavior (no fx/fy set)
|
|
234
|
-
|
|
235
|
-
try { restart(); } catch (e) { /* ignore */ }
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
applyLayout();
|
|
239
|
-
}, [layout, nodes, width, height, restart]);
|
|
240
|
-
|
|
241
|
-
// If manual layout is enabled or any nodes are pinned, disable forces
|
|
242
|
-
useEffect(() => {
|
|
243
|
-
try {
|
|
244
|
-
if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
|
|
245
|
-
else setForcesEnabled(true);
|
|
246
|
-
} catch (e) {
|
|
247
|
-
// ignore
|
|
248
|
-
}
|
|
249
|
-
}, [manualLayout, pinnedNodes, setForcesEnabled]);
|
|
250
|
-
|
|
251
|
-
// Expose imperative handle for parent components
|
|
252
|
-
useImperativeHandle(
|
|
253
|
-
ref,
|
|
254
|
-
() => ({
|
|
255
|
-
pinAll: () => {
|
|
256
|
-
const newPinned = new Set<string>();
|
|
257
|
-
nodes.forEach((node) => {
|
|
258
|
-
node.fx = node.x;
|
|
259
|
-
node.fy = node.y;
|
|
260
|
-
newPinned.add(node.id);
|
|
261
|
-
});
|
|
262
|
-
setPinnedNodes(newPinned);
|
|
263
|
-
restart();
|
|
264
|
-
},
|
|
265
168
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
169
|
+
// For hierarchical layout, arrange in a grid
|
|
170
|
+
if (layout === 'hierarchical') {
|
|
171
|
+
const cols = Math.ceil(Math.sqrt(initialNodes.length));
|
|
172
|
+
const spacingX = width / (cols + 1);
|
|
173
|
+
const spacingY = height / (Math.ceil(initialNodes.length / cols) + 1);
|
|
174
|
+
return initialNodes.map((n: any, i: number) => ({
|
|
175
|
+
...n,
|
|
176
|
+
x: spacingX * ((i % cols) + 1),
|
|
177
|
+
y: spacingY * (Math.floor(i / cols) + 1),
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
274
180
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
181
|
+
return initialNodes;
|
|
182
|
+
}, [initialNodes, width, height, layout]);
|
|
183
|
+
|
|
184
|
+
// Static links - just use initial links
|
|
185
|
+
const links = initialLinks;
|
|
186
|
+
|
|
187
|
+
// No force simulation - static layout only
|
|
188
|
+
const restart = React.useCallback(() => {
|
|
189
|
+
// No-op for static layout
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const stop = React.useCallback(() => {
|
|
193
|
+
// No-op for static layout
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const setForcesEnabled = React.useCallback((enabled?: boolean) => {
|
|
197
|
+
// No-op for static layout; accept optional `enabled` arg for API compatibility
|
|
198
|
+
void enabled;
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
// Remove package bounds effect - boundary packing disabled for faster convergence
|
|
202
|
+
|
|
203
|
+
// Apply layout-specific positioning when layout changes
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!nodes || nodes.length === 0) return;
|
|
206
|
+
|
|
207
|
+
const applyLayout = () => {
|
|
208
|
+
const centerX = width / 2;
|
|
209
|
+
const centerY = height / 2;
|
|
210
|
+
|
|
211
|
+
if (layout === 'circular') {
|
|
212
|
+
// Place all nodes in a circle
|
|
213
|
+
const radius = Math.min(width, height) * 0.35;
|
|
214
|
+
nodes.forEach((node, i) => {
|
|
215
|
+
const angle = (2 * Math.PI * i) / nodes.length;
|
|
216
|
+
node.fx = centerX + Math.cos(angle) * radius;
|
|
217
|
+
node.fy = centerY + Math.sin(angle) * radius;
|
|
218
|
+
});
|
|
219
|
+
} else if (layout === 'hierarchical') {
|
|
220
|
+
// Place packages in rows, files within packages in columns
|
|
221
|
+
const groups = new Map<string, typeof nodes>();
|
|
222
|
+
nodes.forEach((n: any) => {
|
|
223
|
+
const key = n.packageGroup || n.group || 'root';
|
|
224
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
225
|
+
groups.get(key)!.push(n);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const groupArray = Array.from(groups.entries());
|
|
229
|
+
const cols = Math.ceil(Math.sqrt(groupArray.length));
|
|
230
|
+
const groupSpacingX = (width * 0.8) / cols;
|
|
231
|
+
const groupSpacingY =
|
|
232
|
+
(height * 0.8) / Math.ceil(groupArray.length / cols);
|
|
233
|
+
|
|
234
|
+
groupArray.forEach(([groupKey, groupNodes], gi) => {
|
|
235
|
+
const col = gi % cols;
|
|
236
|
+
const row = Math.floor(gi / cols);
|
|
237
|
+
const groupX = (col + 0.5) * groupSpacingX;
|
|
238
|
+
const groupY = (row + 0.5) * groupSpacingY;
|
|
239
|
+
|
|
240
|
+
// Place group nodes in a small circle within their area
|
|
241
|
+
if (groupKey.startsWith('pkg:') || groupKey === groupKey) {
|
|
242
|
+
groupNodes.forEach((n, ni) => {
|
|
243
|
+
const angle = (2 * Math.PI * ni) / groupNodes.length;
|
|
244
|
+
const r = Math.min(80, 20 + groupNodes.length * 8);
|
|
245
|
+
n.fx = groupX + Math.cos(angle) * r;
|
|
246
|
+
n.fy = groupY + Math.sin(angle) * r;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
// 'force' layout - just restart with default behavior (no fx/fy set)
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
restart();
|
|
255
|
+
} catch (e) {
|
|
256
|
+
void e;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
283
259
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
260
|
+
applyLayout();
|
|
261
|
+
}, [layout, nodes, width, height, restart]);
|
|
262
|
+
|
|
263
|
+
// If manual layout is enabled or any nodes are pinned, disable forces
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
try {
|
|
266
|
+
if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
|
|
267
|
+
else setForcesEnabled(true);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
void e;
|
|
270
|
+
}
|
|
271
|
+
}, [manualLayout, pinnedNodes, setForcesEnabled]);
|
|
272
|
+
|
|
273
|
+
// Expose imperative handle for parent components
|
|
274
|
+
useImperativeHandle(
|
|
275
|
+
ref,
|
|
276
|
+
() => ({
|
|
277
|
+
pinAll: () => {
|
|
278
|
+
const newPinned = new Set<string>();
|
|
279
|
+
nodes.forEach((node) => {
|
|
280
|
+
node.fx = node.x;
|
|
281
|
+
node.fy = node.y;
|
|
282
|
+
newPinned.add(node.id);
|
|
283
|
+
});
|
|
284
|
+
setPinnedNodes(newPinned);
|
|
285
|
+
restart();
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
unpinAll: () => {
|
|
289
|
+
nodes.forEach((node) => {
|
|
290
|
+
node.fx = null;
|
|
291
|
+
node.fy = null;
|
|
292
|
+
});
|
|
293
|
+
setPinnedNodes(new Set());
|
|
294
|
+
restart();
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
resetLayout: () => {
|
|
298
|
+
nodes.forEach((node) => {
|
|
299
|
+
node.fx = null;
|
|
300
|
+
node.fy = null;
|
|
301
|
+
});
|
|
302
|
+
setPinnedNodes(new Set());
|
|
303
|
+
restart();
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
fitView: () => {
|
|
307
|
+
if (!svgRef.current || !nodes.length) return;
|
|
308
|
+
|
|
309
|
+
// Calculate bounds
|
|
310
|
+
let minX = Infinity,
|
|
311
|
+
maxX = -Infinity,
|
|
312
|
+
minY = Infinity,
|
|
313
|
+
maxY = -Infinity;
|
|
314
|
+
nodes.forEach((node) => {
|
|
315
|
+
if (node.x !== undefined && node.y !== undefined) {
|
|
316
|
+
const size = node.size || 10;
|
|
317
|
+
minX = Math.min(minX, node.x - size);
|
|
318
|
+
maxX = Math.max(maxX, node.x + size);
|
|
319
|
+
minY = Math.min(minY, node.y - size);
|
|
320
|
+
maxY = Math.max(maxY, node.y + size);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (!isFinite(minX)) return;
|
|
325
|
+
|
|
326
|
+
const padding = 40;
|
|
327
|
+
const nodeWidth = maxX - minX;
|
|
328
|
+
const nodeHeight = maxY - minY;
|
|
329
|
+
const scale = Math.min(
|
|
330
|
+
(width - padding * 2) / nodeWidth,
|
|
331
|
+
(height - padding * 2) / nodeHeight,
|
|
332
|
+
10
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const centerX = (minX + maxX) / 2;
|
|
336
|
+
const centerY = (minY + maxY) / 2;
|
|
337
|
+
|
|
338
|
+
const x = width / 2 - centerX * scale;
|
|
339
|
+
const y = height / 2 - centerY * scale;
|
|
340
|
+
|
|
341
|
+
if (gRef.current && svgRef.current) {
|
|
342
|
+
const svg = d3.select(svgRef.current);
|
|
343
|
+
const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
|
|
344
|
+
svg
|
|
345
|
+
.transition()
|
|
346
|
+
.duration(300)
|
|
347
|
+
.call(
|
|
348
|
+
d3.zoom<SVGSVGElement, unknown>().transform as any,
|
|
349
|
+
newTransform
|
|
350
|
+
);
|
|
351
|
+
setTransform(newTransform);
|
|
296
352
|
}
|
|
297
|
-
}
|
|
353
|
+
},
|
|
298
354
|
|
|
299
|
-
|
|
355
|
+
getPinnedNodes: () => Array.from(pinnedNodes),
|
|
300
356
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const scale = Math.min(
|
|
305
|
-
(width - padding * 2) / nodeWidth,
|
|
306
|
-
(height - padding * 2) / nodeHeight,
|
|
307
|
-
10
|
|
308
|
-
);
|
|
357
|
+
setDragMode: (enabled: boolean) => {
|
|
358
|
+
internalDragEnabledRef.current = enabled;
|
|
359
|
+
},
|
|
309
360
|
|
|
310
|
-
|
|
311
|
-
|
|
361
|
+
setLayout: (newLayout: LayoutType) => {
|
|
362
|
+
handleLayoutChange(newLayout);
|
|
363
|
+
},
|
|
312
364
|
|
|
313
|
-
|
|
314
|
-
|
|
365
|
+
getLayout: () => layout,
|
|
366
|
+
}),
|
|
367
|
+
[nodes, pinnedNodes, restart, width, height, layout, handleLayoutChange]
|
|
368
|
+
);
|
|
315
369
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
370
|
+
// Notify parent when manual layout mode changes (uses the prop so it's not unused)
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
try {
|
|
373
|
+
if (typeof onManualLayoutChange === 'function')
|
|
374
|
+
onManualLayoutChange(manualLayout);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
void e;
|
|
377
|
+
}
|
|
378
|
+
}, [manualLayout, onManualLayoutChange]);
|
|
323
379
|
|
|
324
|
-
|
|
380
|
+
// Set up zoom behavior
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!enableZoom || !svgRef.current || !gRef.current) return;
|
|
325
383
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
},
|
|
329
|
-
|
|
330
|
-
setLayout: (newLayout: LayoutType) => {
|
|
331
|
-
handleLayoutChange(newLayout);
|
|
332
|
-
},
|
|
333
|
-
|
|
334
|
-
getLayout: () => layout,
|
|
335
|
-
}),
|
|
336
|
-
[nodes, pinnedNodes, restart, width, height, layout, handleLayoutChange]
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// Notify parent when manual layout mode changes (uses the prop so it's not unused)
|
|
340
|
-
useEffect(() => {
|
|
341
|
-
try {
|
|
342
|
-
if (typeof onManualLayoutChange === 'function') onManualLayoutChange(manualLayout);
|
|
343
|
-
} catch (e) {
|
|
344
|
-
// ignore errors from callbacks
|
|
345
|
-
}
|
|
346
|
-
}, [manualLayout, onManualLayoutChange]);
|
|
347
|
-
|
|
348
|
-
// Set up zoom behavior
|
|
349
|
-
useEffect(() => {
|
|
350
|
-
if (!enableZoom || !svgRef.current || !gRef.current) return;
|
|
351
|
-
|
|
352
|
-
const svg = d3.select(svgRef.current);
|
|
353
|
-
const g = d3.select(gRef.current);
|
|
354
|
-
|
|
355
|
-
const zoom = d3
|
|
356
|
-
.zoom<SVGSVGElement, unknown>()
|
|
357
|
-
.scaleExtent([0.1, 10])
|
|
358
|
-
.on('zoom', (event) => {
|
|
359
|
-
g.attr('transform', event.transform);
|
|
360
|
-
transformRef.current = event.transform;
|
|
361
|
-
setTransform(event.transform);
|
|
362
|
-
});
|
|
384
|
+
const svg = d3.select(svgRef.current);
|
|
385
|
+
const g = d3.select(gRef.current);
|
|
363
386
|
|
|
364
|
-
|
|
387
|
+
const zoom = d3
|
|
388
|
+
.zoom<SVGSVGElement, unknown>()
|
|
389
|
+
.scaleExtent([0.1, 10])
|
|
390
|
+
.on('zoom', (event) => {
|
|
391
|
+
g.attr('transform', event.transform);
|
|
392
|
+
transformRef.current = event.transform;
|
|
393
|
+
setTransform(event.transform);
|
|
394
|
+
});
|
|
365
395
|
|
|
366
|
-
|
|
367
|
-
svg.on('.zoom', null);
|
|
368
|
-
};
|
|
369
|
-
}, [enableZoom]);
|
|
396
|
+
svg.call(zoom);
|
|
370
397
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
const g = d3.select(gRef.current);
|
|
377
|
-
g.selectAll<SVGGElement, any>('g.node').each(function (this: SVGGElement) {
|
|
378
|
-
const datum = d3.select(this).datum() as any;
|
|
379
|
-
if (!datum) return;
|
|
380
|
-
d3.select(this).attr('transform', `translate(${datum.x || 0},${datum.y || 0})`);
|
|
381
|
-
});
|
|
398
|
+
return () => {
|
|
399
|
+
svg.on('.zoom', null);
|
|
400
|
+
};
|
|
401
|
+
}, [enableZoom]);
|
|
382
402
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (event.relatedTarget === null) handleWindowUp();
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
window.addEventListener('mousemove', handleWindowMove);
|
|
441
|
-
window.addEventListener('mouseup', handleWindowUp);
|
|
442
|
-
window.addEventListener('mouseout', handleWindowLeave);
|
|
443
|
-
window.addEventListener('blur', handleWindowUp);
|
|
444
|
-
|
|
445
|
-
return () => {
|
|
446
|
-
window.removeEventListener('mousemove', handleWindowMove);
|
|
447
|
-
window.removeEventListener('mouseup', handleWindowUp);
|
|
448
|
-
window.removeEventListener('mouseout', handleWindowLeave);
|
|
449
|
-
window.removeEventListener('blur', handleWindowUp);
|
|
450
|
-
};
|
|
451
|
-
}, [enableDrag]);
|
|
452
|
-
|
|
453
|
-
// Attach d3.drag behavior to node groups rendered by React. This helps make
|
|
454
|
-
// dragging more robust across transforms and pointer behaviors.
|
|
455
|
-
useEffect(() => {
|
|
456
|
-
if (!gRef.current || !enableDrag) return;
|
|
457
|
-
const g = d3.select(gRef.current);
|
|
458
|
-
const dragBehavior = d3
|
|
459
|
-
.drag<SVGGElement, unknown>()
|
|
460
|
-
.on('start', function (event) {
|
|
403
|
+
// Run a one-time DOM positioning pass when nodes/links change so elements
|
|
404
|
+
// rendered by React are positioned to the simulation's seeded coordinates
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
if (!gRef.current) return;
|
|
407
|
+
try {
|
|
408
|
+
const g = d3.select(gRef.current);
|
|
409
|
+
g.selectAll<SVGGElement, any>('g.node').each(function (
|
|
410
|
+
this: SVGGElement
|
|
411
|
+
) {
|
|
412
|
+
const datum = d3.select(this).datum() as any;
|
|
413
|
+
if (!datum) return;
|
|
414
|
+
d3.select(this).attr(
|
|
415
|
+
'transform',
|
|
416
|
+
`translate(${datum.x || 0},${datum.y || 0})`
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
g.selectAll<SVGLineElement, any>('line').each(function (
|
|
421
|
+
this: SVGLineElement
|
|
422
|
+
) {
|
|
423
|
+
const l = d3.select(this).datum() as any;
|
|
424
|
+
if (!l) return;
|
|
425
|
+
const s: any =
|
|
426
|
+
typeof l.source === 'object'
|
|
427
|
+
? l.source
|
|
428
|
+
: nodes.find((n) => n.id === l.source) || l.source;
|
|
429
|
+
const t: any =
|
|
430
|
+
typeof l.target === 'object'
|
|
431
|
+
? l.target
|
|
432
|
+
: nodes.find((n) => n.id === l.target) || l.target;
|
|
433
|
+
if (!s || !t) return;
|
|
434
|
+
d3.select(this)
|
|
435
|
+
.attr('x1', s.x)
|
|
436
|
+
.attr('y1', s.y)
|
|
437
|
+
.attr('x2', t.x)
|
|
438
|
+
.attr('y2', t.y);
|
|
439
|
+
});
|
|
440
|
+
} catch (e) {
|
|
441
|
+
void e;
|
|
442
|
+
}
|
|
443
|
+
}, [nodes, links]);
|
|
444
|
+
|
|
445
|
+
// Set up drag behavior with global listeners for smoother dragging
|
|
446
|
+
const handleDragStart = useCallback(
|
|
447
|
+
(event: React.MouseEvent, node: GraphNode) => {
|
|
448
|
+
if (!enableDrag) return;
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
event.stopPropagation();
|
|
451
|
+
// pause forces while dragging to avoid the whole graph moving
|
|
452
|
+
dragActiveRef.current = true;
|
|
453
|
+
dragNodeRef.current = node;
|
|
454
|
+
node.fx = node.x;
|
|
455
|
+
node.fy = node.y;
|
|
456
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
461
457
|
try {
|
|
462
|
-
|
|
463
|
-
const grp = target.closest?.('g.node') as Element | null;
|
|
464
|
-
const id = grp?.getAttribute('data-id');
|
|
465
|
-
if (!id) return;
|
|
466
|
-
const node = nodes.find((n) => n.id === id) as GraphNode | undefined;
|
|
467
|
-
if (!node) return;
|
|
468
|
-
if (!internalDragEnabledRef.current) return;
|
|
469
|
-
if (!event.active) restart();
|
|
470
|
-
dragActiveRef.current = true;
|
|
471
|
-
dragNodeRef.current = node;
|
|
472
|
-
node.fx = node.x;
|
|
473
|
-
node.fy = node.y;
|
|
474
|
-
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
458
|
+
stop();
|
|
475
459
|
} catch (e) {
|
|
476
|
-
|
|
460
|
+
void e;
|
|
477
461
|
}
|
|
478
|
-
}
|
|
479
|
-
|
|
462
|
+
},
|
|
463
|
+
[enableDrag, restart]
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!enableDrag) return;
|
|
468
|
+
|
|
469
|
+
const handleWindowMove = (event: MouseEvent) => {
|
|
480
470
|
if (!dragActiveRef.current || !dragNodeRef.current) return;
|
|
481
471
|
const svg = svgRef.current;
|
|
482
472
|
if (!svg) return;
|
|
483
473
|
const rect = svg.getBoundingClientRect();
|
|
484
|
-
const
|
|
485
|
-
const
|
|
474
|
+
const t: any = transformRef.current;
|
|
475
|
+
const x = (event.clientX - rect.left - t.x) / t.k;
|
|
476
|
+
const y = (event.clientY - rect.top - t.y) / t.k;
|
|
486
477
|
dragNodeRef.current.fx = x;
|
|
487
478
|
dragNodeRef.current.fy = y;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const handleWindowUp = () => {
|
|
482
|
+
if (!dragActiveRef.current) return;
|
|
483
|
+
// Keep fx/fy set to pin the node where it was dropped.
|
|
484
|
+
try {
|
|
485
|
+
setForcesEnabled(true);
|
|
486
|
+
restart();
|
|
487
|
+
} catch (e) {
|
|
488
|
+
void e;
|
|
489
|
+
}
|
|
492
490
|
dragNodeRef.current = null;
|
|
493
491
|
dragActiveRef.current = false;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const handleWindowLeave = (event: MouseEvent) => {
|
|
495
|
+
if (event.relatedTarget === null) handleWindowUp();
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
window.addEventListener('mousemove', handleWindowMove);
|
|
499
|
+
window.addEventListener('mouseup', handleWindowUp);
|
|
500
|
+
window.addEventListener('mouseout', handleWindowLeave);
|
|
501
|
+
window.addEventListener('blur', handleWindowUp);
|
|
502
|
+
|
|
503
|
+
return () => {
|
|
504
|
+
window.removeEventListener('mousemove', handleWindowMove);
|
|
505
|
+
window.removeEventListener('mouseup', handleWindowUp);
|
|
506
|
+
window.removeEventListener('mouseout', handleWindowLeave);
|
|
507
|
+
window.removeEventListener('blur', handleWindowUp);
|
|
508
|
+
};
|
|
509
|
+
}, [enableDrag]);
|
|
510
|
+
|
|
511
|
+
// Attach d3.drag behavior to node groups rendered by React. This helps make
|
|
512
|
+
// dragging more robust across transforms and pointer behaviors.
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
if (!gRef.current || !enableDrag) return;
|
|
515
|
+
const g = d3.select(gRef.current);
|
|
516
|
+
const dragBehavior = d3
|
|
517
|
+
.drag<SVGGElement, unknown>()
|
|
518
|
+
.on('start', function (event) {
|
|
519
|
+
try {
|
|
520
|
+
const target =
|
|
521
|
+
(event.sourceEvent && (event.sourceEvent.target as Element)) ||
|
|
522
|
+
(event.target as Element);
|
|
523
|
+
const grp = target.closest?.('g.node') as Element | null;
|
|
524
|
+
const id = grp?.getAttribute('data-id');
|
|
525
|
+
if (!id) return;
|
|
526
|
+
const node = nodes.find((n) => n.id === id) as
|
|
527
|
+
| GraphNode
|
|
528
|
+
| undefined;
|
|
529
|
+
if (!node) return;
|
|
530
|
+
if (!internalDragEnabledRef.current) return;
|
|
531
|
+
if (!event.active) restart();
|
|
532
|
+
dragActiveRef.current = true;
|
|
533
|
+
dragNodeRef.current = node;
|
|
534
|
+
node.fx = node.x;
|
|
535
|
+
node.fy = node.y;
|
|
536
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
537
|
+
} catch (e) {
|
|
538
|
+
void e;
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
.on('drag', function (event) {
|
|
542
|
+
if (!dragActiveRef.current || !dragNodeRef.current) return;
|
|
543
|
+
const svg = svgRef.current;
|
|
544
|
+
if (!svg) return;
|
|
545
|
+
const rect = svg.getBoundingClientRect();
|
|
546
|
+
const x =
|
|
547
|
+
(event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
|
|
548
|
+
const y =
|
|
549
|
+
(event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
|
|
550
|
+
dragNodeRef.current.fx = x;
|
|
551
|
+
dragNodeRef.current.fy = y;
|
|
552
|
+
})
|
|
553
|
+
.on('end', function () {
|
|
554
|
+
// re-enable forces when drag ends
|
|
555
|
+
try {
|
|
556
|
+
setForcesEnabled(true);
|
|
557
|
+
restart();
|
|
558
|
+
} catch (e) {
|
|
559
|
+
void e;
|
|
560
|
+
}
|
|
561
|
+
});
|
|
501
562
|
|
|
502
|
-
return () => {
|
|
503
563
|
try {
|
|
504
|
-
g.selectAll('g.node').
|
|
564
|
+
g.selectAll('g.node').call(dragBehavior as any);
|
|
505
565
|
} catch (e) {
|
|
506
|
-
|
|
566
|
+
void e;
|
|
507
567
|
}
|
|
508
|
-
};
|
|
509
|
-
}, [gRef, enableDrag, nodes, transform, restart]);
|
|
510
568
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
569
|
+
return () => {
|
|
570
|
+
try {
|
|
571
|
+
g.selectAll('g.node').on('.drag', null as any);
|
|
572
|
+
} catch (e) {
|
|
573
|
+
void e;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}, [gRef, enableDrag, nodes, transform, restart]);
|
|
517
577
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
578
|
+
const handleNodeClick = useCallback(
|
|
579
|
+
(node: GraphNode) => {
|
|
580
|
+
onNodeClick?.(node);
|
|
581
|
+
},
|
|
582
|
+
[onNodeClick]
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const handleNodeDoubleClick = useCallback(
|
|
586
|
+
(event: React.MouseEvent, node: GraphNode) => {
|
|
587
|
+
event.stopPropagation();
|
|
588
|
+
if (!enableDrag) return;
|
|
589
|
+
if (node.fx === null || node.fx === undefined) {
|
|
590
|
+
node.fx = node.x;
|
|
591
|
+
node.fy = node.y;
|
|
592
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
593
|
+
} else {
|
|
594
|
+
node.fx = null;
|
|
595
|
+
node.fy = null;
|
|
596
|
+
setPinnedNodes((prev) => {
|
|
597
|
+
const next = new Set(prev);
|
|
598
|
+
next.delete(node.id);
|
|
599
|
+
return next;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
restart();
|
|
603
|
+
},
|
|
604
|
+
[enableDrag, restart]
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const handleCanvasDoubleClick = useCallback(() => {
|
|
608
|
+
nodes.forEach((node) => {
|
|
527
609
|
node.fx = null;
|
|
528
610
|
node.fy = null;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
next.delete(node.id);
|
|
532
|
-
return next;
|
|
533
|
-
});
|
|
534
|
-
}
|
|
611
|
+
});
|
|
612
|
+
setPinnedNodes(new Set());
|
|
535
613
|
restart();
|
|
536
|
-
},
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
});
|
|
545
|
-
setPinnedNodes(new Set());
|
|
546
|
-
restart();
|
|
547
|
-
}, [nodes, restart]);
|
|
548
|
-
|
|
549
|
-
const handleNodeMouseEnter = useCallback(
|
|
550
|
-
(node: GraphNode) => {
|
|
551
|
-
onNodeHover?.(node);
|
|
552
|
-
},
|
|
553
|
-
[onNodeHover]
|
|
554
|
-
);
|
|
614
|
+
}, [nodes, restart]);
|
|
615
|
+
|
|
616
|
+
const handleNodeMouseEnter = useCallback(
|
|
617
|
+
(node: GraphNode) => {
|
|
618
|
+
onNodeHover?.(node);
|
|
619
|
+
},
|
|
620
|
+
[onNodeHover]
|
|
621
|
+
);
|
|
555
622
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
623
|
+
const handleNodeMouseLeave = useCallback(() => {
|
|
624
|
+
onNodeHover?.(null);
|
|
625
|
+
}, [onNodeHover]);
|
|
559
626
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
</
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
</
|
|
652
|
-
|
|
653
|
-
);
|
|
627
|
+
const handleLinkClick = useCallback(
|
|
628
|
+
(link: GraphLink) => {
|
|
629
|
+
onLinkClick?.(link);
|
|
630
|
+
},
|
|
631
|
+
[onLinkClick]
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<svg
|
|
636
|
+
ref={svgRef}
|
|
637
|
+
width={width}
|
|
638
|
+
height={height}
|
|
639
|
+
className={cn('bg-white dark:bg-gray-900', className)}
|
|
640
|
+
onDoubleClick={handleCanvasDoubleClick}
|
|
641
|
+
>
|
|
642
|
+
<defs>
|
|
643
|
+
{/* Arrow marker for directed graphs */}
|
|
644
|
+
<marker
|
|
645
|
+
id="arrow"
|
|
646
|
+
viewBox="0 0 10 10"
|
|
647
|
+
refX="20"
|
|
648
|
+
refY="5"
|
|
649
|
+
markerWidth="6"
|
|
650
|
+
markerHeight="6"
|
|
651
|
+
orient="auto"
|
|
652
|
+
>
|
|
653
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={defaultLinkColor} />
|
|
654
|
+
</marker>
|
|
655
|
+
</defs>
|
|
656
|
+
|
|
657
|
+
<g ref={gRef}>
|
|
658
|
+
{/* Render links via LinkItem (positions updated by D3) */}
|
|
659
|
+
{links.map((link, i) => (
|
|
660
|
+
<LinkItem
|
|
661
|
+
key={`link-${i}`}
|
|
662
|
+
link={link as GraphLink}
|
|
663
|
+
onClick={handleLinkClick}
|
|
664
|
+
defaultWidth={defaultLinkWidth}
|
|
665
|
+
showLabel={showLinkLabels}
|
|
666
|
+
nodes={nodes}
|
|
667
|
+
/>
|
|
668
|
+
))}
|
|
669
|
+
|
|
670
|
+
{/* Render nodes via NodeItem (D3 will set transforms) */}
|
|
671
|
+
{nodes.map((node) => (
|
|
672
|
+
<NodeItem
|
|
673
|
+
key={node.id}
|
|
674
|
+
node={node as GraphNode}
|
|
675
|
+
isSelected={selectedNodeId === node.id}
|
|
676
|
+
isHovered={hoveredNodeId === node.id}
|
|
677
|
+
pinned={pinnedNodes.has(node.id)}
|
|
678
|
+
defaultNodeSize={defaultNodeSize}
|
|
679
|
+
defaultNodeColor={defaultNodeColor}
|
|
680
|
+
showLabel={showNodeLabels}
|
|
681
|
+
onClick={handleNodeClick}
|
|
682
|
+
onDoubleClick={handleNodeDoubleClick}
|
|
683
|
+
onMouseEnter={handleNodeMouseEnter}
|
|
684
|
+
onMouseLeave={handleNodeMouseLeave}
|
|
685
|
+
onMouseDown={handleDragStart}
|
|
686
|
+
/>
|
|
687
|
+
))}
|
|
688
|
+
{/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
|
|
689
|
+
{packageBounds && Object.keys(packageBounds).length > 0 && (
|
|
690
|
+
<g className="package-boundaries" pointerEvents="none">
|
|
691
|
+
{Object.entries(packageBounds).map(([pid, b]) => (
|
|
692
|
+
<g key={pid}>
|
|
693
|
+
<circle
|
|
694
|
+
cx={b.x}
|
|
695
|
+
cy={b.y}
|
|
696
|
+
r={b.r}
|
|
697
|
+
fill="rgba(148,163,184,0.06)"
|
|
698
|
+
stroke="#475569"
|
|
699
|
+
strokeWidth={2}
|
|
700
|
+
strokeDasharray="6 6"
|
|
701
|
+
opacity={0.9}
|
|
702
|
+
/>
|
|
703
|
+
<text
|
|
704
|
+
x={b.x}
|
|
705
|
+
y={Math.max(12, b.y - b.r + 14)}
|
|
706
|
+
fill="#475569"
|
|
707
|
+
fontSize={11}
|
|
708
|
+
textAnchor="middle"
|
|
709
|
+
pointerEvents="none"
|
|
710
|
+
>
|
|
711
|
+
{pid.replace(/^pkg:/, '')}
|
|
712
|
+
</text>
|
|
713
|
+
</g>
|
|
714
|
+
))}
|
|
715
|
+
</g>
|
|
716
|
+
)}
|
|
717
|
+
</g>
|
|
718
|
+
</svg>
|
|
719
|
+
);
|
|
654
720
|
}
|
|
655
721
|
);
|
|
656
722
|
|
|
657
|
-
ForceDirectedGraph.displayName = 'ForceDirectedGraph';
|
|
723
|
+
ForceDirectedGraph.displayName = 'ForceDirectedGraph';
|