@aiready/components 0.1.0 → 0.1.4

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.
@@ -1,12 +1,9 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
2
2
  import * as d3 from 'd3';
3
- import {
4
- useForceSimulation,
5
- type SimulationNode,
6
- type SimulationLink,
7
- type ForceSimulationOptions,
8
- } from '../hooks/useForceSimulation';
3
+ import { useForceSimulation, type SimulationNode, type SimulationLink, type ForceSimulationOptions } from '../hooks/useForceSimulation';
9
4
  import { cn } from '../utils/cn';
5
+ import NodeItem from './NodeItem';
6
+ import LinkItem from './LinkItem';
10
7
 
11
8
  export interface GraphNode extends SimulationNode {
12
9
  id: string;
@@ -14,6 +11,8 @@ export interface GraphNode extends SimulationNode {
14
11
  color?: string;
15
12
  size?: number;
16
13
  group?: string;
14
+ kind?: 'file' | 'package';
15
+ packageGroup?: string;
17
16
  }
18
17
 
19
18
  export interface GraphLink extends SimulationLink {
@@ -22,143 +21,416 @@ export interface GraphLink extends SimulationLink {
22
21
  label?: string;
23
22
  }
24
23
 
24
+ export interface ForceDirectedGraphHandle {
25
+ pinAll: () => void;
26
+ unpinAll: () => void;
27
+ resetLayout: () => void;
28
+ fitView: () => void;
29
+ getPinnedNodes: () => string[];
30
+ setDragMode: (enabled: boolean) => void;
31
+ }
32
+
25
33
  export interface ForceDirectedGraphProps {
26
- /**
27
- * Array of nodes to display
28
- */
29
34
  nodes: GraphNode[];
30
-
31
- /**
32
- * Array of links between nodes
33
- */
34
35
  links: GraphLink[];
35
-
36
- /**
37
- * Width of the graph container
38
- */
39
36
  width: number;
40
-
41
- /**
42
- * Height of the graph container
43
- */
44
37
  height: number;
45
-
46
- /**
47
- * Force simulation options
48
- */
49
38
  simulationOptions?: Partial<ForceSimulationOptions>;
50
-
51
- /**
52
- * Whether to enable zoom and pan
53
- * @default true
54
- */
55
39
  enableZoom?: boolean;
56
-
57
- /**
58
- * Whether to enable node dragging
59
- * @default true
60
- */
61
40
  enableDrag?: boolean;
62
-
63
- /**
64
- * Callback when a node is clicked
65
- */
66
41
  onNodeClick?: (node: GraphNode) => void;
67
-
68
- /**
69
- * Callback when a node is hovered
70
- */
71
42
  onNodeHover?: (node: GraphNode | null) => void;
72
-
73
- /**
74
- * Callback when a link is clicked
75
- */
76
43
  onLinkClick?: (link: GraphLink) => void;
77
-
78
- /**
79
- * Selected node ID
80
- */
81
44
  selectedNodeId?: string;
82
-
83
- /**
84
- * Hovered node ID
85
- */
86
45
  hoveredNodeId?: string;
87
-
88
- /**
89
- * Default node color
90
- * @default "#69b3a2"
91
- */
92
46
  defaultNodeColor?: string;
93
-
94
- /**
95
- * Default node size
96
- * @default 10
97
- */
98
47
  defaultNodeSize?: number;
99
-
100
- /**
101
- * Default link color
102
- * @default "#999"
103
- */
104
48
  defaultLinkColor?: string;
105
-
106
- /**
107
- * Default link width
108
- * @default 1
109
- */
110
49
  defaultLinkWidth?: number;
111
-
112
- /**
113
- * Whether to show node labels
114
- * @default true
115
- */
116
50
  showNodeLabels?: boolean;
117
-
118
- /**
119
- * Whether to show link labels
120
- * @default false
121
- */
122
51
  showLinkLabels?: boolean;
123
-
124
- /**
125
- * Additional CSS classes
126
- */
127
52
  className?: string;
128
- }
129
-
130
- export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
131
- nodes: initialNodes,
132
- links: initialLinks,
133
- width,
134
- height,
135
- simulationOptions,
136
- enableZoom = true,
137
- enableDrag = true,
138
- onNodeClick,
139
- onNodeHover,
140
- onLinkClick,
141
- selectedNodeId,
142
- hoveredNodeId,
143
- defaultNodeColor = '#69b3a2',
144
- defaultNodeSize = 10,
145
- defaultLinkColor = '#999',
146
- defaultLinkWidth = 1,
147
- showNodeLabels = true,
148
- showLinkLabels = false,
149
- className,
150
- }) => {
53
+ manualLayout?: boolean;
54
+ onManualLayoutChange?: (enabled: boolean) => void;
55
+ packageBounds?: Record<string, { x: number; y: number; r: number }>;
56
+ }
57
+
58
+ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
59
+ (
60
+ {
61
+ nodes: initialNodes,
62
+ links: initialLinks,
63
+ width,
64
+ height,
65
+ simulationOptions,
66
+ enableZoom = true,
67
+ enableDrag = true,
68
+ onNodeClick,
69
+ onNodeHover,
70
+ onLinkClick,
71
+ selectedNodeId,
72
+ hoveredNodeId,
73
+ defaultNodeColor = '#69b3a2',
74
+ defaultNodeSize = 10,
75
+ defaultLinkColor = '#999',
76
+ defaultLinkWidth = 1,
77
+ showNodeLabels = true,
78
+ showLinkLabels = false,
79
+ className,
80
+ manualLayout = false,
81
+ onManualLayoutChange,
82
+ packageBounds,
83
+ },
84
+ ref
85
+ ) => {
151
86
  const svgRef = useRef<SVGSVGElement>(null);
152
87
  const gRef = useRef<SVGGElement>(null);
153
88
  const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
89
+ const transformRef = useRef(transform);
90
+ const dragNodeRef = useRef<GraphNode | null>(null);
91
+ const dragActiveRef = useRef(false);
92
+ const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
93
+ const internalDragEnabledRef = useRef(enableDrag);
154
94
 
155
- // Initialize simulation
156
- const { nodes, links, restart } = useForceSimulation(initialNodes, initialLinks, {
95
+ // Update the ref when enableDrag prop changes
96
+ useEffect(() => {
97
+ internalDragEnabledRef.current = enableDrag;
98
+ }, [enableDrag]);
99
+
100
+ // Initialize simulation - let React handle rendering based on node positions
101
+ const onTick = (_nodesCopy: any[], _linksCopy: any[], _sim: any) => {
102
+ // If package bounds are provided, gently pull file nodes toward their
103
+ // package center to create meaningful clusters.
104
+ try {
105
+ const boundsToUse = clusterBounds?.bounds ?? packageBounds;
106
+ const nodeClusterMap = clusterBounds?.nodeToCluster ?? {};
107
+ if (boundsToUse) {
108
+ Object.values(nodesById).forEach((n) => {
109
+ if (!n) return;
110
+ // Prefer explicit `group`, but fall back to `packageGroup` which is
111
+ // provided by the visualizer data. This ensures file nodes are
112
+ // pulled toward their package center (pkg:<name>) as intended.
113
+ const group = (n as any).group ?? (n as any).packageGroup as string | undefined;
114
+ const clusterKey = nodeClusterMap[n.id];
115
+ const key = clusterKey ?? (group ? `pkg:${group}` : undefined);
116
+ if (!key) return;
117
+ const center = (boundsToUse as any)[key];
118
+ if (!center) return;
119
+ const dx = center.x - (n.x ?? 0);
120
+ const dy = center.y - (n.y ?? 0);
121
+ const dist = Math.sqrt(dx * dx + dy * dy);
122
+ // Much stronger pull so nodes reliably settle inside cluster areas
123
+ const pullStrength = Math.min(0.5, 0.15 * (dist / (center.r || 200)) + 0.06);
124
+ if (!isNaN(pullStrength) && isFinite(pullStrength)) {
125
+ n.vx = (n.vx ?? 0) + (dx / (dist || 1)) * pullStrength;
126
+ n.vy = (n.vy ?? 0) + (dy / (dist || 1)) * pullStrength;
127
+ }
128
+ // If outside cluster radius, apply a stronger inward correction scaled to excess
129
+ if (center.r && dist > center.r) {
130
+ const excess = (dist - center.r) / (dist || 1);
131
+ n.vx = (n.vx ?? 0) - dx * 0.02 * excess;
132
+ n.vy = (n.vy ?? 0) - dy * 0.02 * excess;
133
+ }
134
+ });
135
+ }
136
+ } catch (e) {
137
+ // ignore grouping errors
138
+ }
139
+
140
+ // No DOM updates needed - React will re-render based on nodes state from useForceSimulation
141
+ // The useForceSimulation hook already calls setNodes on each tick (throttled)
142
+ // React components (NodeItem, LinkItem) will use node.x, node.y from the nodes state
143
+ };
144
+
145
+ // main simulation is created after seeding below (so seededNodes can be used)
146
+
147
+ // --- Two-phase hierarchical layout: Phase A (package centers) + Phase B (local layouts)
148
+ // Compute package areas and per-node local positions, then seed the main simulation.
149
+ const { packageAreas, localPositions } = React.useMemo(() => {
150
+ try {
151
+ if (!initialNodes || !initialNodes.length) return { packageAreas: {}, localPositions: {} };
152
+ // Group nodes by package/group key
153
+ const groups = new Map<string, any[]>();
154
+ initialNodes.forEach((n: any) => {
155
+ const key = n.packageGroup || n.group || 'root';
156
+ if (!groups.has(key)) groups.set(key, []);
157
+ groups.get(key)!.push(n);
158
+ });
159
+
160
+ const groupKeys = Array.from(groups.keys());
161
+ // Build pack layout for package centers
162
+ const children = groupKeys.map((k) => ({ name: k, value: Math.max(1, groups.get(k)!.length) }));
163
+ const root: any = d3.hierarchy({ children } as any);
164
+ root.sum((d: any) => d.value);
165
+ const pack: any = d3.pack().size([width, height]).padding(Math.max(20, Math.min(width, height) * 0.03));
166
+ const packed: any = pack(root);
167
+ const packageAreas: Record<string, { x: number; y: number; r: number }> = {};
168
+ if (packed.children) {
169
+ packed.children.forEach((c: any) => {
170
+ const name = c.data.name;
171
+ packageAreas[name] = { x: c.x, y: c.y, r: Math.max(40, c.r) };
172
+ });
173
+ }
174
+
175
+ // For each package, run a short local force simulation offscreen to compute local positions
176
+ const localPositions: Record<string, { x: number; y: number }> = {};
177
+ groups.forEach((nodesInGroup, _key) => {
178
+ if (!nodesInGroup || nodesInGroup.length === 0) return;
179
+ // create shallow copies for the local sim
180
+ const localNodes = nodesInGroup.map((n: any) => ({ id: n.id, x: Math.random() * 10 - 5, y: Math.random() * 10 - 5, size: n.size || 10 }));
181
+ // links restricted to intra-package
182
+ const localLinks = (initialLinks || []).filter((l: any) => {
183
+ const s = typeof l.source === 'string' ? l.source : (l.source && l.source.id);
184
+ const t = typeof l.target === 'string' ? l.target : (l.target && l.target.id);
185
+ return localNodes.some((ln: any) => ln.id === s) && localNodes.some((ln: any) => ln.id === t);
186
+ }).map((l: any) => ({ source: typeof l.source === 'string' ? l.source : l.source.id, target: typeof l.target === 'string' ? l.target : l.target.id }));
187
+
188
+ if (localNodes.length === 1) {
189
+ localPositions[localNodes[0].id] = { x: 0, y: 0 };
190
+ return;
191
+ }
192
+
193
+ const sim = d3.forceSimulation(localNodes as any)
194
+ .force('link', d3.forceLink(localLinks as any).id((d: any) => d.id).distance(30).strength(0.8))
195
+ .force('charge', d3.forceManyBody().strength(-15))
196
+ .force('collide', d3.forceCollide((d: any) => (d.size || 10) + 6).iterations(2))
197
+ .stop();
198
+
199
+ // Run several synchronous ticks to settle local layout
200
+ const ticks = 300;
201
+ for (let i = 0; i < ticks; i++) sim.tick();
202
+
203
+ localNodes.forEach((ln: any) => {
204
+ localPositions[ln.id] = { x: ln.x ?? 0, y: ln.y ?? 0 };
205
+ });
206
+ });
207
+
208
+ return { packageAreas, localPositions };
209
+ } catch (e) {
210
+ return { packageAreas: {}, localPositions: {} };
211
+ }
212
+ }, [initialNodes, initialLinks, width, height]);
213
+
214
+ // Seed main simulation nodes with package-local coordinates mapped into package areas
215
+ const seededNodes = React.useMemo(() => {
216
+ if (!initialNodes || !Object.keys(packageAreas || {}).length) return initialNodes;
217
+ return initialNodes.map((n: any) => {
218
+ const key = n.packageGroup || n.group || 'root';
219
+ const area = packageAreas[key];
220
+ const lp = localPositions[n.id];
221
+ if (!area || !lp) return n;
222
+ // scale local layout to fit inside package radius
223
+ const scale = Math.max(0.5, (area.r * 0.6) / (Math.max(1, Math.sqrt(lp.x * lp.x + lp.y * lp.y)) || 1));
224
+ return { ...n, x: area.x + lp.x * scale, y: area.y + lp.y * scale };
225
+ });
226
+ }, [initialNodes, packageAreas, localPositions]);
227
+
228
+
229
+ // Compute dependency-based clusters (connected components on dependency links)
230
+ // create the main force simulation using seeded nodes
231
+ const { nodes, links, restart, stop, setForcesEnabled } = useForceSimulation(seededNodes || initialNodes, initialLinks, {
157
232
  width,
158
233
  height,
234
+ chargeStrength: manualLayout ? 0 : undefined,
235
+ onTick,
159
236
  ...simulationOptions,
160
237
  });
161
238
 
239
+ // Helper map id -> node for quick lookup in onTick
240
+ const nodesById = React.useMemo(() => {
241
+ const m: Record<string, any> = {};
242
+ (nodes || []).forEach((n: any) => {
243
+ if (n && n.id) m[n.id] = n;
244
+ });
245
+ return m;
246
+ }, [nodes]);
247
+
248
+ const clusterBounds = React.useMemo(() => {
249
+ try {
250
+ if (!links || !nodes) return null;
251
+ const nodeIds = new Set(nodes.map((n) => n.id));
252
+ const adj = new Map<string, Set<string>>();
253
+ nodes.forEach((n) => adj.set(n.id, new Set()));
254
+ links.forEach((l: any) => {
255
+ const type = l.type || 'reference';
256
+ if (type !== 'dependency') return;
257
+ const s = typeof l.source === 'string' ? l.source : (l.source && l.source.id) || null;
258
+ const t = typeof l.target === 'string' ? l.target : (l.target && l.target.id) || null;
259
+ if (!s || !t) return;
260
+ if (!nodeIds.has(s) || !nodeIds.has(t)) return;
261
+ adj.get(s)?.add(t);
262
+ adj.get(t)?.add(s);
263
+ });
264
+
265
+ const visited = new Set<string>();
266
+ const comps: Array<string[]> = [];
267
+ for (const nid of nodeIds) {
268
+ if (visited.has(nid)) continue;
269
+ const stack = [nid];
270
+ const comp: string[] = [];
271
+ visited.add(nid);
272
+ while (stack.length) {
273
+ const cur = stack.pop()!;
274
+ comp.push(cur);
275
+ const neigh = adj.get(cur);
276
+ if (!neigh) continue;
277
+ for (const nb of neigh) {
278
+ if (!visited.has(nb)) {
279
+ visited.add(nb);
280
+ stack.push(nb);
281
+ }
282
+ }
283
+ }
284
+ comps.push(comp);
285
+ }
286
+
287
+ if (comps.length <= 1) return null;
288
+
289
+ // Increase spread: scale the packing area slightly larger than viewport,
290
+ // give more padding between clusters, and bias radii upward so nodes
291
+ // have more room to sit inside cluster circles.
292
+ const children = comps.map((c, i) => ({ name: String(i), value: Math.max(1, c.length) }));
293
+ d3.hierarchy({ children } as any).sum((d: any) => d.value).sort((a: any, b: any) => b.value - a.value);
294
+ // Use a radial layout to guarantee very large separation between clusters.
295
+ // Place cluster centers on a circle around the viewport center with a
296
+ // radius scaled aggressively by viewport size and cluster count.
297
+ const num = comps.length;
298
+ const cx = width / 2;
299
+ const cy = height / 2;
300
+ // Circle radius grows with viewport and number of clusters to force separation
301
+ const base = Math.max(width, height);
302
+ // Make cluster circle radius extremely large so clusters are very far apart.
303
+ // Scale with number of clusters to avoid crowding; multiply heavily for drastic separation.
304
+ const circleRadius = base * Math.max(30, num * 20, Math.sqrt(num) * 12);
305
+ const map: Record<string, { x: number; y: number; r: number }> = {};
306
+ comps.forEach((c, i) => {
307
+ const angle = (2 * Math.PI * i) / num;
308
+ const x = cx + Math.cos(angle) * circleRadius;
309
+ const y = cy + Math.sin(angle) * circleRadius;
310
+ const sizeBias = Math.sqrt(Math.max(1, c.length));
311
+ const r = Math.max(200, 100 * sizeBias);
312
+ map[`cluster:${i}`] = { x, y, r };
313
+ });
314
+ // Map node id -> cluster id center
315
+ const nodeToCluster: Record<string, string> = {};
316
+ comps.forEach((c, i) => c.forEach((nid) => (nodeToCluster[nid] = `cluster:${i}`)));
317
+ return { bounds: map, nodeToCluster };
318
+ } catch (e) {
319
+ return null;
320
+ }
321
+ }, [nodes, links, width, height]);
322
+
323
+ // If package or cluster bounds are provided, recreate the simulation so onTick gets the latest bounds
324
+ useEffect(() => {
325
+ if (!packageBounds && !clusterBounds && (!packageAreas || Object.keys(packageAreas).length === 0)) return;
326
+ try {
327
+ restart();
328
+ } catch (e) {
329
+ // ignore
330
+ }
331
+ }, [packageBounds, clusterBounds, packageAreas, restart]);
332
+
333
+ // If manual layout is enabled or any nodes are pinned, disable forces
334
+ useEffect(() => {
335
+ try {
336
+ if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
337
+ else setForcesEnabled(true);
338
+ } catch (e) {
339
+ // ignore
340
+ }
341
+ }, [manualLayout, pinnedNodes, setForcesEnabled]);
342
+
343
+ // Expose imperative handle for parent components
344
+ useImperativeHandle(
345
+ ref,
346
+ () => ({
347
+ pinAll: () => {
348
+ const newPinned = new Set<string>();
349
+ nodes.forEach((node) => {
350
+ node.fx = node.x;
351
+ node.fy = node.y;
352
+ newPinned.add(node.id);
353
+ });
354
+ setPinnedNodes(newPinned);
355
+ restart();
356
+ },
357
+
358
+ unpinAll: () => {
359
+ nodes.forEach((node) => {
360
+ node.fx = null;
361
+ node.fy = null;
362
+ });
363
+ setPinnedNodes(new Set());
364
+ restart();
365
+ },
366
+
367
+ resetLayout: () => {
368
+ nodes.forEach((node) => {
369
+ node.fx = null;
370
+ node.fy = null;
371
+ });
372
+ setPinnedNodes(new Set());
373
+ restart();
374
+ },
375
+
376
+ fitView: () => {
377
+ if (!svgRef.current || !nodes.length) return;
378
+
379
+ // Calculate bounds
380
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
381
+ nodes.forEach((node) => {
382
+ if (node.x !== undefined && node.y !== undefined) {
383
+ const size = node.size || 10;
384
+ minX = Math.min(minX, node.x - size);
385
+ maxX = Math.max(maxX, node.x + size);
386
+ minY = Math.min(minY, node.y - size);
387
+ maxY = Math.max(maxY, node.y + size);
388
+ }
389
+ });
390
+
391
+ if (!isFinite(minX)) return;
392
+
393
+ const padding = 40;
394
+ const nodeWidth = maxX - minX;
395
+ const nodeHeight = maxY - minY;
396
+ const scale = Math.min(
397
+ (width - padding * 2) / nodeWidth,
398
+ (height - padding * 2) / nodeHeight,
399
+ 10
400
+ );
401
+
402
+ const centerX = (minX + maxX) / 2;
403
+ const centerY = (minY + maxY) / 2;
404
+
405
+ const x = width / 2 - centerX * scale;
406
+ const y = height / 2 - centerY * scale;
407
+
408
+ if (gRef.current && svgRef.current) {
409
+ const svg = d3.select(svgRef.current);
410
+ const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
411
+ svg.transition().duration(300).call(d3.zoom<SVGSVGElement, unknown>().transform as any, newTransform);
412
+ setTransform(newTransform);
413
+ }
414
+ },
415
+
416
+ getPinnedNodes: () => Array.from(pinnedNodes),
417
+
418
+ setDragMode: (enabled: boolean) => {
419
+ internalDragEnabledRef.current = enabled;
420
+ },
421
+ }),
422
+ [nodes, pinnedNodes, restart, width, height]
423
+ );
424
+
425
+ // Notify parent when manual layout mode changes (uses the prop so it's not unused)
426
+ useEffect(() => {
427
+ try {
428
+ if (typeof onManualLayoutChange === 'function') onManualLayoutChange(manualLayout);
429
+ } catch (e) {
430
+ // ignore errors from callbacks
431
+ }
432
+ }, [manualLayout, onManualLayoutChange]);
433
+
162
434
  // Set up zoom behavior
163
435
  useEffect(() => {
164
436
  if (!enableZoom || !svgRef.current || !gRef.current) return;
@@ -171,6 +443,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
171
443
  .scaleExtent([0.1, 10])
172
444
  .on('zoom', (event) => {
173
445
  g.attr('transform', event.transform);
446
+ transformRef.current = event.transform;
174
447
  setTransform(event.transform);
175
448
  });
176
449
 
@@ -181,43 +454,145 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
181
454
  };
182
455
  }, [enableZoom]);
183
456
 
184
- // Set up drag behavior
457
+ // Run a one-time DOM positioning pass when nodes/links change so elements
458
+ // rendered by React are positioned to the simulation's seeded coordinates
459
+ useEffect(() => {
460
+ if (!gRef.current) return;
461
+ try {
462
+ const g = d3.select(gRef.current);
463
+ g.selectAll<SVGGElement, any>('g.node').each(function (this: SVGGElement) {
464
+ const datum = d3.select(this).datum() as any;
465
+ if (!datum) return;
466
+ d3.select(this).attr('transform', `translate(${datum.x || 0},${datum.y || 0})`);
467
+ });
468
+
469
+ g.selectAll<SVGLineElement, any>('line').each(function (this: SVGLineElement) {
470
+ const l = d3.select(this).datum() as any;
471
+ if (!l) return;
472
+ const s: any = typeof l.source === 'object' ? l.source : nodes.find((n) => n.id === l.source) || l.source;
473
+ const t: any = typeof l.target === 'object' ? l.target : nodes.find((n) => n.id === l.target) || l.target;
474
+ if (!s || !t) return;
475
+ d3.select(this).attr('x1', s.x).attr('y1', s.y).attr('x2', t.x).attr('y2', t.y);
476
+ });
477
+ } catch (e) {
478
+ // ignore
479
+ }
480
+ }, [nodes, links]);
481
+
482
+ // Set up drag behavior with global listeners for smoother dragging
185
483
  const handleDragStart = useCallback(
186
484
  (event: React.MouseEvent, node: GraphNode) => {
187
485
  if (!enableDrag) return;
486
+ event.preventDefault();
188
487
  event.stopPropagation();
488
+ // pause forces while dragging to avoid the whole graph moving
489
+ dragActiveRef.current = true;
490
+ dragNodeRef.current = node;
189
491
  node.fx = node.x;
190
492
  node.fy = node.y;
191
- restart();
493
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
494
+ try { stop(); } catch (e) {}
192
495
  },
193
496
  [enableDrag, restart]
194
497
  );
195
498
 
196
- const handleDrag = useCallback(
197
- (event: React.MouseEvent, node: GraphNode) => {
198
- if (!enableDrag) return;
499
+ useEffect(() => {
500
+ if (!enableDrag) return;
501
+
502
+ const handleWindowMove = (event: MouseEvent) => {
503
+ if (!dragActiveRef.current || !dragNodeRef.current) return;
199
504
  const svg = svgRef.current;
200
505
  if (!svg) return;
201
-
202
506
  const rect = svg.getBoundingClientRect();
203
- const x = (event.clientX - rect.left - transform.x) / transform.k;
204
- const y = (event.clientY - rect.top - transform.y) / transform.k;
507
+ const t: any = transformRef.current;
508
+ const x = (event.clientX - rect.left - t.x) / t.k;
509
+ const y = (event.clientY - rect.top - t.y) / t.k;
510
+ dragNodeRef.current.fx = x;
511
+ dragNodeRef.current.fy = y;
512
+ };
205
513
 
206
- node.fx = x;
207
- node.fy = y;
208
- },
209
- [enableDrag, transform]
210
- );
514
+ const handleWindowUp = () => {
515
+ if (!dragActiveRef.current) return;
516
+ // Keep fx/fy set to pin the node where it was dropped.
517
+ try { setForcesEnabled(true); restart(); } catch (e) {}
518
+ dragNodeRef.current = null;
519
+ dragActiveRef.current = false;
520
+ };
211
521
 
212
- const handleDragEnd = useCallback(
213
- (event: React.MouseEvent, node: GraphNode) => {
214
- if (!enableDrag) return;
215
- event.stopPropagation();
216
- node.fx = null;
217
- node.fy = null;
218
- },
219
- [enableDrag]
220
- );
522
+ const handleWindowLeave = (event: MouseEvent) => {
523
+ if (event.relatedTarget === null) handleWindowUp();
524
+ };
525
+
526
+ window.addEventListener('mousemove', handleWindowMove);
527
+ window.addEventListener('mouseup', handleWindowUp);
528
+ window.addEventListener('mouseout', handleWindowLeave);
529
+ window.addEventListener('blur', handleWindowUp);
530
+
531
+ return () => {
532
+ window.removeEventListener('mousemove', handleWindowMove);
533
+ window.removeEventListener('mouseup', handleWindowUp);
534
+ window.removeEventListener('mouseout', handleWindowLeave);
535
+ window.removeEventListener('blur', handleWindowUp);
536
+ };
537
+ }, [enableDrag]);
538
+
539
+ // Attach d3.drag behavior to node groups rendered by React. This helps make
540
+ // dragging more robust across transforms and pointer behaviors.
541
+ useEffect(() => {
542
+ if (!gRef.current || !enableDrag) return;
543
+ const g = d3.select(gRef.current);
544
+ const dragBehavior = d3
545
+ .drag<SVGGElement, unknown>()
546
+ .on('start', function (event) {
547
+ try {
548
+ const target = (event.sourceEvent && (event.sourceEvent.target as Element)) || (event.target as Element);
549
+ const grp = target.closest?.('g.node') as Element | null;
550
+ const id = grp?.getAttribute('data-id');
551
+ if (!id) return;
552
+ const node = nodes.find((n) => n.id === id) as GraphNode | undefined;
553
+ if (!node) return;
554
+ if (!internalDragEnabledRef.current) return;
555
+ if (!event.active) restart();
556
+ dragActiveRef.current = true;
557
+ dragNodeRef.current = node;
558
+ node.fx = node.x;
559
+ node.fy = node.y;
560
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
561
+ } catch (e) {
562
+ // ignore
563
+ }
564
+ })
565
+ .on('drag', function (event) {
566
+ if (!dragActiveRef.current || !dragNodeRef.current) return;
567
+ const svg = svgRef.current;
568
+ if (!svg) return;
569
+ const rect = svg.getBoundingClientRect();
570
+ const x = (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
571
+ const y = (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
572
+ dragNodeRef.current.fx = x;
573
+ dragNodeRef.current.fy = y;
574
+ })
575
+ .on('end', function () {
576
+ // re-enable forces when drag ends
577
+ try { setForcesEnabled(true); restart(); } catch (e) {}
578
+ dragNodeRef.current = null;
579
+ dragActiveRef.current = false;
580
+ });
581
+
582
+ try {
583
+ g.selectAll('g.node').call(dragBehavior as any);
584
+ } catch (e) {
585
+ // ignore attach errors
586
+ }
587
+
588
+ return () => {
589
+ try {
590
+ g.selectAll('g.node').on('.drag', null as any);
591
+ } catch (e) {
592
+ /* ignore */
593
+ }
594
+ };
595
+ }, [gRef, enableDrag, nodes, transform, restart]);
221
596
 
222
597
  const handleNodeClick = useCallback(
223
598
  (node: GraphNode) => {
@@ -226,6 +601,37 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
226
601
  [onNodeClick]
227
602
  );
228
603
 
604
+ const handleNodeDoubleClick = useCallback(
605
+ (event: React.MouseEvent, node: GraphNode) => {
606
+ event.stopPropagation();
607
+ if (!enableDrag) return;
608
+ if (node.fx === null || node.fx === undefined) {
609
+ node.fx = node.x;
610
+ node.fy = node.y;
611
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
612
+ } else {
613
+ node.fx = null;
614
+ node.fy = null;
615
+ setPinnedNodes((prev) => {
616
+ const next = new Set(prev);
617
+ next.delete(node.id);
618
+ return next;
619
+ });
620
+ }
621
+ restart();
622
+ },
623
+ [enableDrag, restart]
624
+ );
625
+
626
+ const handleCanvasDoubleClick = useCallback(() => {
627
+ nodes.forEach((node) => {
628
+ node.fx = null;
629
+ node.fy = null;
630
+ });
631
+ setPinnedNodes(new Set());
632
+ restart();
633
+ }, [nodes, restart]);
634
+
229
635
  const handleNodeMouseEnter = useCallback(
230
636
  (node: GraphNode) => {
231
637
  onNodeHover?.(node);
@@ -250,6 +656,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
250
656
  width={width}
251
657
  height={height}
252
658
  className={cn('bg-white dark:bg-gray-900', className)}
659
+ onDoubleClick={handleCanvasDoubleClick}
253
660
  >
254
661
  <defs>
255
662
  {/* Arrow marker for directed graphs */}
@@ -267,90 +674,70 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
267
674
  </defs>
268
675
 
269
676
  <g ref={gRef}>
270
- {/* Render links */}
271
- {links.map((link, i) => {
272
- const source = link.source as GraphNode;
273
- const target = link.target as GraphNode;
274
- if (!source.x || !source.y || !target.x || !target.y) return null;
275
-
276
- return (
277
- <g key={`link-${i}`}>
278
- <line
279
- x1={source.x}
280
- y1={source.y}
281
- x2={target.x}
282
- y2={target.y}
283
- stroke={link.color || defaultLinkColor}
284
- strokeWidth={link.width || defaultLinkWidth}
285
- opacity={0.6}
286
- className="cursor-pointer transition-opacity hover:opacity-100"
287
- onClick={() => handleLinkClick(link)}
288
- />
289
- {showLinkLabels && link.label && (
290
- <text
291
- x={(source.x + target.x) / 2}
292
- y={(source.y + target.y) / 2}
293
- fill="#666"
294
- fontSize="10"
295
- textAnchor="middle"
296
- dominantBaseline="middle"
297
- pointerEvents="none"
298
- >
299
- {link.label}
300
- </text>
301
- )}
302
- </g>
303
- );
304
- })}
305
-
306
- {/* Render nodes */}
307
- {nodes.map((node) => {
308
- if (!node.x || !node.y) return null;
309
-
310
- const isSelected = selectedNodeId === node.id;
311
- const isHovered = hoveredNodeId === node.id;
312
- const nodeSize = node.size || defaultNodeSize;
313
- const nodeColor = node.color || defaultNodeColor;
314
-
315
- return (
316
- <g
317
- key={node.id}
318
- transform={`translate(${node.x},${node.y})`}
319
- className="cursor-pointer"
320
- onClick={() => handleNodeClick(node)}
321
- onMouseEnter={() => handleNodeMouseEnter(node)}
322
- onMouseLeave={handleNodeMouseLeave}
323
- onMouseDown={(e) => handleDragStart(e, node)}
324
- onMouseMove={(e) => handleDrag(e, node)}
325
- onMouseUp={(e) => handleDragEnd(e, node)}
326
- >
327
- <circle
328
- r={nodeSize}
329
- fill={nodeColor}
330
- stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
331
- strokeWidth={isSelected ? 3 : 2}
332
- opacity={isHovered || isSelected ? 1 : 0.9}
333
- className="transition-all"
334
- />
335
- {showNodeLabels && node.label && (
677
+
678
+ {/* Render links via LinkItem (positions updated by D3) */}
679
+ {links.map((link, i) => (
680
+ <LinkItem
681
+ key={`link-${i}`}
682
+ link={link as GraphLink}
683
+ onClick={handleLinkClick}
684
+ defaultWidth={defaultLinkWidth}
685
+ showLabel={showLinkLabels}
686
+ nodes={nodes}
687
+ />
688
+ ))}
689
+
690
+ {/* Render nodes via NodeItem (D3 will set transforms) */}
691
+ {nodes.map((node) => (
692
+ <NodeItem
693
+ key={node.id}
694
+ node={node as GraphNode}
695
+ isSelected={selectedNodeId === node.id}
696
+ isHovered={hoveredNodeId === node.id}
697
+ pinned={pinnedNodes.has(node.id)}
698
+ defaultNodeSize={defaultNodeSize}
699
+ defaultNodeColor={defaultNodeColor}
700
+ showLabel={showNodeLabels}
701
+ onClick={handleNodeClick}
702
+ onDoubleClick={handleNodeDoubleClick}
703
+ onMouseEnter={handleNodeMouseEnter}
704
+ onMouseLeave={handleNodeMouseLeave}
705
+ onMouseDown={handleDragStart}
706
+ />
707
+ ))}
708
+ {/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
709
+ {packageBounds && Object.keys(packageBounds).length > 0 && (
710
+ <g className="package-boundaries" pointerEvents="none">
711
+ {Object.entries(packageBounds).map(([pid, b]) => (
712
+ <g key={pid}>
713
+ <circle
714
+ cx={b.x}
715
+ cy={b.y}
716
+ r={b.r}
717
+ fill="rgba(148,163,184,0.06)"
718
+ stroke="#475569"
719
+ strokeWidth={2}
720
+ strokeDasharray="6 6"
721
+ opacity={0.9}
722
+ />
336
723
  <text
337
- y={nodeSize + 15}
338
- fill="#333"
339
- fontSize="12"
724
+ x={b.x}
725
+ y={Math.max(12, b.y - b.r + 14)}
726
+ fill="#475569"
727
+ fontSize={11}
340
728
  textAnchor="middle"
341
- dominantBaseline="middle"
342
729
  pointerEvents="none"
343
- className="select-none"
344
730
  >
345
- {node.label}
731
+ {pid.replace(/^pkg:/, '')}
346
732
  </text>
347
- )}
348
- </g>
349
- );
350
- })}
733
+ </g>
734
+ ))}
735
+ </g>
736
+ )}
351
737
  </g>
352
738
  </svg>
353
739
  );
354
- };
740
+ }
741
+ );
355
742
 
356
743
  ForceDirectedGraph.displayName = 'ForceDirectedGraph';