@aiready/components 0.1.3 → 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
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 {
@@ -23,157 +22,38 @@ export interface GraphLink extends SimulationLink {
23
22
  }
24
23
 
25
24
  export interface ForceDirectedGraphHandle {
26
- /**
27
- * Pin all nodes in place
28
- */
29
25
  pinAll: () => void;
30
-
31
- /**
32
- * Unpin all nodes (release constraints)
33
- */
34
26
  unpinAll: () => void;
35
-
36
- /**
37
- * Reset all nodes to auto-layout (unpin and restart simulation)
38
- */
39
27
  resetLayout: () => void;
40
-
41
- /**
42
- * Fit all nodes in the current view
43
- */
44
28
  fitView: () => void;
45
-
46
- /**
47
- * Get currently pinned node IDs
48
- */
49
29
  getPinnedNodes: () => string[];
50
-
51
- /**
52
- * Toggle dragging mode
53
- */
54
30
  setDragMode: (enabled: boolean) => void;
55
31
  }
56
32
 
57
33
  export interface ForceDirectedGraphProps {
58
- /**
59
- * Array of nodes to display
60
- */
61
34
  nodes: GraphNode[];
62
-
63
- /**
64
- * Array of links between nodes
65
- */
66
35
  links: GraphLink[];
67
-
68
- /**
69
- * Width of the graph container
70
- */
71
36
  width: number;
72
-
73
- /**
74
- * Height of the graph container
75
- */
76
37
  height: number;
77
-
78
- /**
79
- * Force simulation options
80
- */
81
38
  simulationOptions?: Partial<ForceSimulationOptions>;
82
-
83
- /**
84
- * Whether to enable zoom and pan
85
- * @default true
86
- */
87
39
  enableZoom?: boolean;
88
-
89
- /**
90
- * Whether to enable node dragging
91
- * @default true
92
- */
93
40
  enableDrag?: boolean;
94
-
95
- /**
96
- * Callback when a node is clicked
97
- */
98
41
  onNodeClick?: (node: GraphNode) => void;
99
-
100
- /**
101
- * Callback when a node is hovered
102
- */
103
42
  onNodeHover?: (node: GraphNode | null) => void;
104
-
105
- /**
106
- * Callback when a link is clicked
107
- */
108
43
  onLinkClick?: (link: GraphLink) => void;
109
-
110
- /**
111
- * Selected node ID
112
- */
113
44
  selectedNodeId?: string;
114
-
115
- /**
116
- * Hovered node ID
117
- */
118
45
  hoveredNodeId?: string;
119
-
120
- /**
121
- * Default node color
122
- * @default "#69b3a2"
123
- */
124
46
  defaultNodeColor?: string;
125
-
126
- /**
127
- * Default node size
128
- * @default 10
129
- */
130
47
  defaultNodeSize?: number;
131
-
132
- /**
133
- * Default link color
134
- * @default "#999"
135
- */
136
48
  defaultLinkColor?: string;
137
-
138
- /**
139
- * Default link width
140
- * @default 1
141
- */
142
49
  defaultLinkWidth?: number;
143
-
144
- /**
145
- * Whether to show node labels
146
- * @default true
147
- */
148
50
  showNodeLabels?: boolean;
149
-
150
- /**
151
- * Whether to show link labels
152
- * @default false
153
- */
154
51
  showLinkLabels?: boolean;
155
-
156
- /**
157
- * Additional CSS classes
158
- */
159
52
  className?: string;
160
-
161
- /**
162
- * Manual layout mode: disables forces, allows free dragging
163
- * @default false
164
- */
165
53
  manualLayout?: boolean;
166
-
167
- /**
168
- * Callback when manual layout mode is toggled
169
- */
170
54
  onManualLayoutChange?: (enabled: boolean) => void;
171
-
172
- /**
173
- * Package bounds computed by the parent (pack layout): map of `pkg:group` -> {x,y,r}
174
- */
175
55
  packageBounds?: Record<string, { x: number; y: number; r: number }>;
176
- }
56
+ }
177
57
 
178
58
  export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
179
59
  (
@@ -206,6 +86,7 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
206
86
  const svgRef = useRef<SVGSVGElement>(null);
207
87
  const gRef = useRef<SVGGElement>(null);
208
88
  const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
89
+ const transformRef = useRef(transform);
209
90
  const dragNodeRef = useRef<GraphNode | null>(null);
210
91
  const dragActiveRef = useRef(false);
211
92
  const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
@@ -216,67 +97,138 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
216
97
  internalDragEnabledRef.current = enableDrag;
217
98
  }, [enableDrag]);
218
99
 
219
- // Initialize simulation with manualLayout mode
220
- const onTick = (nodesCopy: any[], _linksCopy: any[], _sim: any) => {
221
- const bounds = packageBounds && Object.keys(packageBounds).length ? packageBounds : undefined;
222
- // fallback: if parent didn't provide packageBounds, compute locally from initialNodes
223
- let effectiveBounds = bounds;
224
- if (!effectiveBounds) {
225
- try {
226
- const counts: Record<string, number> = {};
227
- (initialNodes || []).forEach((n: any) => {
228
- if (n && n.kind === 'file') {
229
- const g = n.packageGroup || 'root';
230
- counts[g] = (counts[g] || 0) + 1;
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;
231
127
  }
232
- });
233
- const children = Object.keys(counts).map((k) => ({ name: k, value: counts[k] }));
234
- if (children.length > 0) {
235
- const root = d3.hierarchy<any>({ children } as any).sum((d: any) => d.value as number);
236
- const pack = d3.pack().size([width, height]).padding(30);
237
- const packed = pack(root);
238
- const map: Record<string, { x: number; y: number; r: number }> = {};
239
- if (packed.children) {
240
- packed.children.forEach((c: any) => {
241
- map[`pkg:${c.data.name}`] = { x: c.x, y: c.y, r: c.r * 0.95 };
242
- });
243
- effectiveBounds = map;
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;
244
133
  }
245
- }
246
- } catch (e) {
247
- // ignore fallback errors
134
+ });
248
135
  }
136
+ } catch (e) {
137
+ // ignore grouping errors
249
138
  }
250
- if (!effectiveBounds) return;
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(() => {
251
150
  try {
252
- Object.values(nodesCopy).forEach((n: any) => {
253
- if (!n) return;
254
- // only constrain file nodes (package nodes have their own fx/fy)
255
- if (n.kind === 'package') return;
256
- const pkg = n.packageGroup;
257
- if (!pkg) return;
258
- const bound = effectiveBounds[`pkg:${pkg}`];
259
- if (!bound) return;
260
- const margin = (n.size || 10) + 12;
261
- const dx = (n.x || 0) - bound.x;
262
- const dy = (n.y || 0) - bound.y;
263
- const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
264
- const maxDist = Math.max(1, bound.r - margin);
265
- if (dist > maxDist) {
266
- const desiredX = bound.x + dx * (maxDist / dist);
267
- const desiredY = bound.y + dy * (maxDist / dist);
268
- // apply a soft corrective velocity toward the desired position
269
- const softness = 0.08;
270
- n.vx = (n.vx || 0) + (desiredX - n.x) * softness;
271
- n.vy = (n.vy || 0) + (desiredY - n.y) * softness;
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;
272
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
+ });
273
206
  });
207
+
208
+ return { packageAreas, localPositions };
274
209
  } catch (e) {
275
- // ignore
210
+ return { packageAreas: {}, localPositions: {} };
276
211
  }
277
- };
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
+
278
228
 
279
- const { nodes, links, restart, stop, setForcesEnabled } = useForceSimulation(initialNodes, initialLinks, {
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, {
280
232
  width,
281
233
  height,
282
234
  chargeStrength: manualLayout ? 0 : undefined,
@@ -284,13 +236,99 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
284
236
  ...simulationOptions,
285
237
  });
286
238
 
287
- // If package bounds are provided, add a tick-time clamp via the hook's onTick option
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
288
324
  useEffect(() => {
289
- if (!packageBounds) return;
290
- // nothing to do here because the hook will call onTick passed in creation; we need to recreate simulation to use onTick
291
- // So restart the simulation to pick up potential changes in node bounds.
292
- try { restart(); } catch (e) {}
293
- }, [packageBounds, restart]);
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]);
294
332
 
295
333
  // If manual layout is enabled or any nodes are pinned, disable forces
296
334
  useEffect(() => {
@@ -405,6 +443,7 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
405
443
  .scaleExtent([0.1, 10])
406
444
  .on('zoom', (event) => {
407
445
  g.attr('transform', event.transform);
446
+ transformRef.current = event.transform;
408
447
  setTransform(event.transform);
409
448
  });
410
449
 
@@ -415,6 +454,31 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
415
454
  };
416
455
  }, [enableZoom]);
417
456
 
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
+
418
482
  // Set up drag behavior with global listeners for smoother dragging
419
483
  const handleDragStart = useCallback(
420
484
  (event: React.MouseEvent, node: GraphNode) => {
@@ -440,8 +504,9 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
440
504
  const svg = svgRef.current;
441
505
  if (!svg) return;
442
506
  const rect = svg.getBoundingClientRect();
443
- const x = (event.clientX - rect.left - transform.x) / transform.k;
444
- 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;
445
510
  dragNodeRef.current.fx = x;
446
511
  dragNodeRef.current.fy = y;
447
512
  };
@@ -469,7 +534,7 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
469
534
  window.removeEventListener('mouseout', handleWindowLeave);
470
535
  window.removeEventListener('blur', handleWindowUp);
471
536
  };
472
- }, [enableDrag, transform]);
537
+ }, [enableDrag]);
473
538
 
474
539
  // Attach d3.drag behavior to node groups rendered by React. This helps make
475
540
  // dragging more robust across transforms and pointer behaviors.
@@ -610,97 +675,36 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
610
675
 
611
676
  <g ref={gRef}>
612
677
 
613
- {/* Render links */}
614
- {links.map((link, i) => {
615
- const source = link.source as GraphNode;
616
- const target = link.target as GraphNode;
617
- if (source.x == null || source.y == null || target.x == null || target.y == null) return null;
618
-
619
- return (
620
- <g key={`link-${i}`}>
621
- <line
622
- x1={source.x}
623
- y1={source.y}
624
- x2={target.x}
625
- y2={target.y}
626
- stroke={link.color || defaultLinkColor}
627
- strokeWidth={link.width || defaultLinkWidth}
628
- opacity={0.6}
629
- className="cursor-pointer transition-opacity hover:opacity-100"
630
- onClick={() => handleLinkClick(link)}
631
- />
632
- {showLinkLabels && link.label && (
633
- <text
634
- x={(source.x + target.x) / 2}
635
- y={(source.y + target.y) / 2}
636
- fill="#666"
637
- fontSize="10"
638
- textAnchor="middle"
639
- dominantBaseline="middle"
640
- pointerEvents="none"
641
- >
642
- {link.label}
643
- </text>
644
- )}
645
- </g>
646
- );
647
- })}
648
-
649
- {/* Render nodes */}
650
- {nodes.map((node) => {
651
- if (node.x == null || node.y == null) return null;
652
-
653
- const isSelected = selectedNodeId === node.id;
654
- const isHovered = hoveredNodeId === node.id;
655
- const nodeSize = node.size || defaultNodeSize;
656
- const nodeColor = node.color || defaultNodeColor;
657
-
658
- return (
659
- <g
660
- key={node.id}
661
- transform={`translate(${node.x},${node.y})`}
662
- className="cursor-pointer node"
663
- data-id={node.id}
664
- onClick={() => handleNodeClick(node)}
665
- onDoubleClick={(event) => handleNodeDoubleClick(event, node)}
666
- onMouseEnter={() => handleNodeMouseEnter(node)}
667
- onMouseLeave={handleNodeMouseLeave}
668
- onMouseDown={(e) => handleDragStart(e, node)}
669
- >
670
- <circle
671
- r={nodeSize}
672
- fill={nodeColor}
673
- stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
674
- strokeWidth={pinnedNodes.has(node.id) ? 3 : isSelected ? 2.5 : isHovered ? 2 : 1.5}
675
- opacity={isHovered || isSelected ? 1 : 0.9}
676
- className="transition-all"
677
- />
678
- {pinnedNodes.has(node.id) && (
679
- <circle
680
- r={nodeSize + 4}
681
- fill="none"
682
- stroke="#ff6b6b"
683
- strokeWidth={1}
684
- opacity={0.5}
685
- className="pointer-events-none"
686
- />
687
- )}
688
- {showNodeLabels && node.label && (
689
- <text
690
- y={nodeSize + 15}
691
- fill="#333"
692
- fontSize="12"
693
- textAnchor="middle"
694
- dominantBaseline="middle"
695
- pointerEvents="none"
696
- className="select-none"
697
- >
698
- {node.label}
699
- </text>
700
- )}
701
- </g>
702
- );
703
- })}
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
+ ))}
704
708
  {/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
705
709
  {packageBounds && Object.keys(packageBounds).length > 0 && (
706
710
  <g className="package-boundaries" pointerEvents="none">