@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.
- package/dist/charts/ForceDirectedGraph.d.ts +2 -0
- package/dist/charts/ForceDirectedGraph.js +489 -162
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/hooks/useForceSimulation.d.ts +5 -0
- package/dist/hooks/useForceSimulation.js +181 -19
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.js +489 -162
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/smoke.test.js +4 -0
- package/src/charts/ForceDirectedGraph.tsx +281 -277
- package/src/charts/LinkItem.tsx +74 -0
- package/src/charts/NodeItem.tsx +70 -0
- package/src/hooks/useForceSimulation.ts +220 -36
|
@@ -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
|
|
220
|
-
const onTick = (
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
(
|
|
228
|
-
if (n
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
444
|
-
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;
|
|
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
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
</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">
|