@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.
- package/dist/charts/ForceDirectedGraph.d.ts +19 -2
- package/dist/charts/ForceDirectedGraph.js +856 -207
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/hooks/useForceSimulation.d.ts +9 -1
- package/dist/hooks/useForceSimulation.js +210 -19
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +989 -205
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/smoke.test.js +4 -0
- package/src/__tests__/smoke.test.ts +5 -0
- package/src/charts/ForceDirectedGraph.tsx +601 -214
- package/src/charts/GraphControls.tsx +218 -0
- package/src/charts/LinkItem.tsx +74 -0
- package/src/charts/NodeItem.tsx +70 -0
- package/src/hooks/useForceSimulation.ts +259 -29
- package/src/index.ts +4 -0
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
493
|
+
setPinnedNodes((prev) => new Set([...prev, node.id]));
|
|
494
|
+
try { stop(); } catch (e) {}
|
|
192
495
|
},
|
|
193
496
|
[enableDrag, restart]
|
|
194
497
|
);
|
|
195
498
|
|
|
196
|
-
|
|
197
|
-
(
|
|
198
|
-
|
|
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
|
|
204
|
-
const
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
271
|
-
{links
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
{
|
|
731
|
+
{pid.replace(/^pkg:/, '')}
|
|
346
732
|
</text>
|
|
347
|
-
|
|
348
|
-
|
|
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';
|