@aiready/components 0.1.31 → 0.1.34

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.
Files changed (73) hide show
  1. package/README.md +2 -2
  2. package/dist/charts/ForceDirectedGraph.js +49 -13
  3. package/dist/charts/ForceDirectedGraph.js.map +1 -1
  4. package/dist/components/badge.js.map +1 -1
  5. package/dist/components/button.js.map +1 -1
  6. package/dist/components/card.js.map +1 -1
  7. package/dist/components/checkbox.js.map +1 -1
  8. package/dist/components/container.js.map +1 -1
  9. package/dist/components/grid.js.map +1 -1
  10. package/dist/components/input.d.ts +1 -2
  11. package/dist/components/input.js.map +1 -1
  12. package/dist/components/label.js +1 -8
  13. package/dist/components/label.js.map +1 -1
  14. package/dist/components/radio-group.js.map +1 -1
  15. package/dist/components/select.js.map +1 -1
  16. package/dist/components/separator.js.map +1 -1
  17. package/dist/components/stack.js.map +1 -1
  18. package/dist/components/switch.js +29 -22
  19. package/dist/components/switch.js.map +1 -1
  20. package/dist/components/textarea.d.ts +1 -2
  21. package/dist/components/textarea.js.map +1 -1
  22. package/dist/hooks/useD3.js.map +1 -1
  23. package/dist/hooks/useDebounce.js.map +1 -1
  24. package/dist/hooks/useForceSimulation.d.ts +1 -0
  25. package/dist/hooks/useForceSimulation.js +37 -14
  26. package/dist/hooks/useForceSimulation.js.map +1 -1
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.js +337 -141
  29. package/dist/index.js.map +1 -1
  30. package/dist/utils/cn.js.map +1 -1
  31. package/dist/utils/colors.js.map +1 -1
  32. package/dist/utils/formatters.js.map +1 -1
  33. package/package.json +3 -2
  34. package/src/__tests__/smoke.test.js +1 -1
  35. package/src/__tests__/smoke.test.ts +3 -3
  36. package/src/charts/ForceDirectedGraph.tsx +583 -517
  37. package/src/charts/GraphControls.tsx +5 -2
  38. package/src/charts/LinkItem.tsx +17 -5
  39. package/src/charts/NodeItem.tsx +17 -2
  40. package/src/code-block/CodeBlock.tsx +53 -16
  41. package/src/code-block/index.ts +1 -1
  42. package/src/components/badge.tsx +3 -2
  43. package/src/components/button.tsx +3 -2
  44. package/src/components/card.tsx +8 -1
  45. package/src/components/checkbox.tsx +6 -4
  46. package/src/components/container.tsx +1 -1
  47. package/src/components/grid.tsx +1 -1
  48. package/src/components/input.tsx +2 -3
  49. package/src/components/label.tsx +4 -7
  50. package/src/components/radio-group.tsx +5 -3
  51. package/src/components/select.tsx +5 -3
  52. package/src/components/separator.tsx +1 -1
  53. package/src/components/stack.tsx +1 -1
  54. package/src/components/switch.tsx +15 -7
  55. package/src/components/textarea.tsx +2 -3
  56. package/src/data-display/ScoreBar.tsx +52 -15
  57. package/src/data-display/index.ts +7 -1
  58. package/src/feedback/ErrorDisplay.tsx +17 -4
  59. package/src/feedback/LoadingSpinner.tsx +8 -3
  60. package/src/feedback/index.ts +12 -2
  61. package/src/hooks/useD3.ts +1 -3
  62. package/src/hooks/useDebounce.ts +1 -1
  63. package/src/hooks/useForceSimulation.ts +142 -44
  64. package/src/index.ts +29 -9
  65. package/src/navigation/Breadcrumb.tsx +17 -8
  66. package/src/navigation/index.ts +5 -1
  67. package/src/theme/ThemeProvider.tsx +11 -3
  68. package/src/theme/index.ts +6 -1
  69. package/src/utils/cn.ts +1 -1
  70. package/src/utils/colors.ts +1 -1
  71. package/src/utils/formatters.ts +1 -1
  72. package/src/utils/score.ts +3 -1
  73. package/tailwind.config.js +1 -1
@@ -1,4 +1,11 @@
1
- import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from '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<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
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
- const svgRef = useRef<SVGSVGElement>(null);
99
- const gRef = useRef<SVGGElement>(null);
100
- const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
101
- const transformRef = useRef(transform);
102
- const dragNodeRef = useRef<GraphNode | null>(null);
103
- const dragActiveRef = useRef(false);
104
- const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
105
- const internalDragEnabledRef = useRef(enableDrag);
106
- const [layout, setLayout] = useState<LayoutType>(externalLayout || 'force');
107
-
108
- // Sync external layout prop with internal state
109
- useEffect(() => {
110
- if (externalLayout && externalLayout !== layout) {
111
- setLayout(externalLayout);
112
- }
113
- }, [externalLayout]);
114
-
115
- // Handle layout change and notify parent
116
- const handleLayoutChange = useCallback((newLayout: LayoutType) => {
117
- setLayout(newLayout);
118
- onLayoutChange?.(newLayout);
119
- }, [onLayoutChange]);
120
-
121
- // Update the ref when enableDrag prop changes
122
- useEffect(() => {
123
- internalDragEnabledRef.current = enableDrag;
124
- }, [enableDrag]);
125
-
126
-
127
- // Static layout - compute positions directly without force simulation
128
- const nodes = React.useMemo(() => {
129
- if (!initialNodes || !initialNodes.length) return initialNodes;
130
-
131
- const centerX = width / 2;
132
- const centerY = height / 2;
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
- nodes.forEach((node, i) => {
198
- const angle = (2 * Math.PI * i) / nodes.length;
199
- node.fx = centerX + Math.cos(angle) * radius;
200
- node.fy = centerY + Math.sin(angle) * radius;
201
- });
202
- } else if (layout === 'hierarchical') {
203
- // Place packages in rows, files within packages in columns
204
- const groups = new Map<string, typeof nodes>();
205
- nodes.forEach((n: any) => {
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
- unpinAll: () => {
267
- nodes.forEach((node) => {
268
- node.fx = null;
269
- node.fy = null;
270
- });
271
- setPinnedNodes(new Set());
272
- restart();
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
- resetLayout: () => {
276
- nodes.forEach((node) => {
277
- node.fx = null;
278
- node.fy = null;
279
- });
280
- setPinnedNodes(new Set());
281
- restart();
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
- fitView: () => {
285
- if (!svgRef.current || !nodes.length) return;
286
-
287
- // Calculate bounds
288
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
289
- nodes.forEach((node) => {
290
- if (node.x !== undefined && node.y !== undefined) {
291
- const size = node.size || 10;
292
- minX = Math.min(minX, node.x - size);
293
- maxX = Math.max(maxX, node.x + size);
294
- minY = Math.min(minY, node.y - size);
295
- maxY = Math.max(maxY, node.y + size);
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
- if (!isFinite(minX)) return;
355
+ getPinnedNodes: () => Array.from(pinnedNodes),
300
356
 
301
- const padding = 40;
302
- const nodeWidth = maxX - minX;
303
- const nodeHeight = maxY - minY;
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
- const centerX = (minX + maxX) / 2;
311
- const centerY = (minY + maxY) / 2;
361
+ setLayout: (newLayout: LayoutType) => {
362
+ handleLayoutChange(newLayout);
363
+ },
312
364
 
313
- const x = width / 2 - centerX * scale;
314
- const y = height / 2 - centerY * scale;
365
+ getLayout: () => layout,
366
+ }),
367
+ [nodes, pinnedNodes, restart, width, height, layout, handleLayoutChange]
368
+ );
315
369
 
316
- if (gRef.current && svgRef.current) {
317
- const svg = d3.select(svgRef.current);
318
- const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
319
- svg.transition().duration(300).call(d3.zoom<SVGSVGElement, unknown>().transform as any, newTransform);
320
- setTransform(newTransform);
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
- getPinnedNodes: () => Array.from(pinnedNodes),
380
+ // Set up zoom behavior
381
+ useEffect(() => {
382
+ if (!enableZoom || !svgRef.current || !gRef.current) return;
325
383
 
326
- setDragMode: (enabled: boolean) => {
327
- internalDragEnabledRef.current = enabled;
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
- svg.call(zoom);
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
- return () => {
367
- svg.on('.zoom', null);
368
- };
369
- }, [enableZoom]);
396
+ svg.call(zoom);
370
397
 
371
- // Run a one-time DOM positioning pass when nodes/links change so elements
372
- // rendered by React are positioned to the simulation's seeded coordinates
373
- useEffect(() => {
374
- if (!gRef.current) return;
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
- g.selectAll<SVGLineElement, any>('line').each(function (this: SVGLineElement) {
384
- const l = d3.select(this).datum() as any;
385
- if (!l) return;
386
- const s: any = typeof l.source === 'object' ? l.source : nodes.find((n) => n.id === l.source) || l.source;
387
- const t: any = typeof l.target === 'object' ? l.target : nodes.find((n) => n.id === l.target) || l.target;
388
- if (!s || !t) return;
389
- d3.select(this).attr('x1', s.x).attr('y1', s.y).attr('x2', t.x).attr('y2', t.y);
390
- });
391
- } catch (e) {
392
- // ignore
393
- }
394
- }, [nodes, links]);
395
-
396
- // Set up drag behavior with global listeners for smoother dragging
397
- const handleDragStart = useCallback(
398
- (event: React.MouseEvent, node: GraphNode) => {
399
- if (!enableDrag) return;
400
- event.preventDefault();
401
- event.stopPropagation();
402
- // pause forces while dragging to avoid the whole graph moving
403
- dragActiveRef.current = true;
404
- dragNodeRef.current = node;
405
- node.fx = node.x;
406
- node.fy = node.y;
407
- setPinnedNodes((prev) => new Set([...prev, node.id]));
408
- try { stop(); } catch (e) {}
409
- },
410
- [enableDrag, restart]
411
- );
412
-
413
- useEffect(() => {
414
- if (!enableDrag) return;
415
-
416
- const handleWindowMove = (event: MouseEvent) => {
417
- if (!dragActiveRef.current || !dragNodeRef.current) return;
418
- const svg = svgRef.current;
419
- if (!svg) return;
420
- const rect = svg.getBoundingClientRect();
421
- const t: any = transformRef.current;
422
- const x = (event.clientX - rect.left - t.x) / t.k;
423
- const y = (event.clientY - rect.top - t.y) / t.k;
424
- dragNodeRef.current.fx = x;
425
- dragNodeRef.current.fy = y;
426
- };
427
-
428
- const handleWindowUp = () => {
429
- if (!dragActiveRef.current) return;
430
- // Keep fx/fy set to pin the node where it was dropped.
431
- try { setForcesEnabled(true); restart(); } catch (e) {}
432
- dragNodeRef.current = null;
433
- dragActiveRef.current = false;
434
- };
435
-
436
- const handleWindowLeave = (event: MouseEvent) => {
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
- const target = (event.sourceEvent && (event.sourceEvent.target as Element)) || (event.target as Element);
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
- // ignore
460
+ void e;
477
461
  }
478
- })
479
- .on('drag', function (event) {
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 x = (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
485
- const y = (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
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
- .on('end', function () {
490
- // re-enable forces when drag ends
491
- try { setForcesEnabled(true); restart(); } catch (e) {}
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
- try {
497
- g.selectAll('g.node').call(dragBehavior as any);
498
- } catch (e) {
499
- // ignore attach errors
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').on('.drag', null as any);
564
+ g.selectAll('g.node').call(dragBehavior as any);
505
565
  } catch (e) {
506
- /* ignore */
566
+ void e;
507
567
  }
508
- };
509
- }, [gRef, enableDrag, nodes, transform, restart]);
510
568
 
511
- const handleNodeClick = useCallback(
512
- (node: GraphNode) => {
513
- onNodeClick?.(node);
514
- },
515
- [onNodeClick]
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
- const handleNodeDoubleClick = useCallback(
519
- (event: React.MouseEvent, node: GraphNode) => {
520
- event.stopPropagation();
521
- if (!enableDrag) return;
522
- if (node.fx === null || node.fx === undefined) {
523
- node.fx = node.x;
524
- node.fy = node.y;
525
- setPinnedNodes((prev) => new Set([...prev, node.id]));
526
- } else {
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
- setPinnedNodes((prev) => {
530
- const next = new Set(prev);
531
- next.delete(node.id);
532
- return next;
533
- });
534
- }
611
+ });
612
+ setPinnedNodes(new Set());
535
613
  restart();
536
- },
537
- [enableDrag, restart]
538
- );
539
-
540
- const handleCanvasDoubleClick = useCallback(() => {
541
- nodes.forEach((node) => {
542
- node.fx = null;
543
- node.fy = null;
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
- const handleNodeMouseLeave = useCallback(() => {
557
- onNodeHover?.(null);
558
- }, [onNodeHover]);
623
+ const handleNodeMouseLeave = useCallback(() => {
624
+ onNodeHover?.(null);
625
+ }, [onNodeHover]);
559
626
 
560
- const handleLinkClick = useCallback(
561
- (link: GraphLink) => {
562
- onLinkClick?.(link);
563
- },
564
- [onLinkClick]
565
- );
566
-
567
- return (
568
- <svg
569
- ref={svgRef}
570
- width={width}
571
- height={height}
572
- className={cn('bg-white dark:bg-gray-900', className)}
573
- onDoubleClick={handleCanvasDoubleClick}
574
- >
575
- <defs>
576
- {/* Arrow marker for directed graphs */}
577
- <marker
578
- id="arrow"
579
- viewBox="0 0 10 10"
580
- refX="20"
581
- refY="5"
582
- markerWidth="6"
583
- markerHeight="6"
584
- orient="auto"
585
- >
586
- <path d="M 0 0 L 10 5 L 0 10 z" fill={defaultLinkColor} />
587
- </marker>
588
- </defs>
589
-
590
- <g ref={gRef}>
591
-
592
- {/* Render links via LinkItem (positions updated by D3) */}
593
- {links.map((link, i) => (
594
- <LinkItem
595
- key={`link-${i}`}
596
- link={link as GraphLink}
597
- onClick={handleLinkClick}
598
- defaultWidth={defaultLinkWidth}
599
- showLabel={showLinkLabels}
600
- nodes={nodes}
601
- />
602
- ))}
603
-
604
- {/* Render nodes via NodeItem (D3 will set transforms) */}
605
- {nodes.map((node) => (
606
- <NodeItem
607
- key={node.id}
608
- node={node as GraphNode}
609
- isSelected={selectedNodeId === node.id}
610
- isHovered={hoveredNodeId === node.id}
611
- pinned={pinnedNodes.has(node.id)}
612
- defaultNodeSize={defaultNodeSize}
613
- defaultNodeColor={defaultNodeColor}
614
- showLabel={showNodeLabels}
615
- onClick={handleNodeClick}
616
- onDoubleClick={handleNodeDoubleClick}
617
- onMouseEnter={handleNodeMouseEnter}
618
- onMouseLeave={handleNodeMouseLeave}
619
- onMouseDown={handleDragStart}
620
- />
621
- ))}
622
- {/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
623
- {packageBounds && Object.keys(packageBounds).length > 0 && (
624
- <g className="package-boundaries" pointerEvents="none">
625
- {Object.entries(packageBounds).map(([pid, b]) => (
626
- <g key={pid}>
627
- <circle
628
- cx={b.x}
629
- cy={b.y}
630
- r={b.r}
631
- fill="rgba(148,163,184,0.06)"
632
- stroke="#475569"
633
- strokeWidth={2}
634
- strokeDasharray="6 6"
635
- opacity={0.9}
636
- />
637
- <text
638
- x={b.x}
639
- y={Math.max(12, b.y - b.r + 14)}
640
- fill="#475569"
641
- fontSize={11}
642
- textAnchor="middle"
643
- pointerEvents="none"
644
- >
645
- {pid.replace(/^pkg:/, '')}
646
- </text>
647
- </g>
648
- ))}
649
- </g>
650
- )}
651
- </g>
652
- </svg>
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';