@aiready/components 0.1.6 → 0.1.8
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 +15 -6
- package/dist/charts/ForceDirectedGraph.js +112 -452
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +98 -171
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/ForceDirectedGraph.tsx +148 -234
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Unified shared components library (UI, charts, hooks, utilities) for AIReady",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"d3": "^7.9.0",
|
|
64
64
|
"d3-force": "^3.0.0",
|
|
65
65
|
"tailwind-merge": "^2.6.1",
|
|
66
|
-
"@aiready/core": "0.9.
|
|
66
|
+
"@aiready/core": "0.9.9"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.6.5",
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
|
2
2
|
import * as d3 from 'd3';
|
|
3
|
-
import { useForceSimulation, type SimulationNode, type SimulationLink, type ForceSimulationOptions } from '../hooks/useForceSimulation';
|
|
4
3
|
import { cn } from '../utils/cn';
|
|
5
4
|
import NodeItem from './NodeItem';
|
|
6
5
|
import LinkItem from './LinkItem';
|
|
7
6
|
|
|
8
|
-
export interface GraphNode
|
|
7
|
+
export interface GraphNode {
|
|
9
8
|
id: string;
|
|
10
9
|
label?: string;
|
|
11
10
|
color?: string;
|
|
@@ -13,14 +12,23 @@ export interface GraphNode extends SimulationNode {
|
|
|
13
12
|
group?: string;
|
|
14
13
|
kind?: 'file' | 'package';
|
|
15
14
|
packageGroup?: string;
|
|
15
|
+
x?: number;
|
|
16
|
+
y?: number;
|
|
17
|
+
fx?: number | null;
|
|
18
|
+
fy?: number | null;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export interface GraphLink
|
|
21
|
+
export interface GraphLink {
|
|
22
|
+
source: string | GraphNode;
|
|
23
|
+
target: string | GraphNode;
|
|
19
24
|
color?: string;
|
|
20
25
|
width?: number;
|
|
21
26
|
label?: string;
|
|
27
|
+
type?: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
export type LayoutType = 'force' | 'hierarchical' | 'circular';
|
|
31
|
+
|
|
24
32
|
export interface ForceDirectedGraphHandle {
|
|
25
33
|
pinAll: () => void;
|
|
26
34
|
unpinAll: () => void;
|
|
@@ -28,6 +36,8 @@ export interface ForceDirectedGraphHandle {
|
|
|
28
36
|
fitView: () => void;
|
|
29
37
|
getPinnedNodes: () => string[];
|
|
30
38
|
setDragMode: (enabled: boolean) => void;
|
|
39
|
+
setLayout: (layout: LayoutType) => void;
|
|
40
|
+
getLayout: () => LayoutType;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
export interface ForceDirectedGraphProps {
|
|
@@ -35,7 +45,6 @@ export interface ForceDirectedGraphProps {
|
|
|
35
45
|
links: GraphLink[];
|
|
36
46
|
width: number;
|
|
37
47
|
height: number;
|
|
38
|
-
simulationOptions?: Partial<ForceSimulationOptions>;
|
|
39
48
|
enableZoom?: boolean;
|
|
40
49
|
enableDrag?: boolean;
|
|
41
50
|
onNodeClick?: (node: GraphNode) => void;
|
|
@@ -53,7 +62,9 @@ export interface ForceDirectedGraphProps {
|
|
|
53
62
|
manualLayout?: boolean;
|
|
54
63
|
onManualLayoutChange?: (enabled: boolean) => void;
|
|
55
64
|
packageBounds?: Record<string, { x: number; y: number; r: number }>;
|
|
56
|
-
|
|
65
|
+
layout?: LayoutType;
|
|
66
|
+
onLayoutChange?: (layout: LayoutType) => void;
|
|
67
|
+
}
|
|
57
68
|
|
|
58
69
|
export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
|
|
59
70
|
(
|
|
@@ -62,7 +73,6 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
62
73
|
links: initialLinks,
|
|
63
74
|
width,
|
|
64
75
|
height,
|
|
65
|
-
simulationOptions,
|
|
66
76
|
enableZoom = true,
|
|
67
77
|
enableDrag = true,
|
|
68
78
|
onNodeClick,
|
|
@@ -80,6 +90,8 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
80
90
|
manualLayout = false,
|
|
81
91
|
onManualLayoutChange,
|
|
82
92
|
packageBounds,
|
|
93
|
+
layout: externalLayout,
|
|
94
|
+
onLayoutChange,
|
|
83
95
|
},
|
|
84
96
|
ref
|
|
85
97
|
) => {
|
|
@@ -91,244 +103,140 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
91
103
|
const dragActiveRef = useRef(false);
|
|
92
104
|
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
|
|
93
105
|
const internalDragEnabledRef = useRef(enableDrag);
|
|
106
|
+
const [layout, setLayout] = useState<LayoutType>(externalLayout || 'force');
|
|
107
|
+
|
|
108
|
+
// Sync external layout prop with internal state
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (externalLayout && externalLayout !== layout) {
|
|
111
|
+
setLayout(externalLayout);
|
|
112
|
+
}
|
|
113
|
+
}, [externalLayout]);
|
|
114
|
+
|
|
115
|
+
// Handle layout change and notify parent
|
|
116
|
+
const handleLayoutChange = useCallback((newLayout: LayoutType) => {
|
|
117
|
+
setLayout(newLayout);
|
|
118
|
+
onLayoutChange?.(newLayout);
|
|
119
|
+
}, [onLayoutChange]);
|
|
94
120
|
|
|
95
121
|
// Update the ref when enableDrag prop changes
|
|
96
122
|
useEffect(() => {
|
|
97
123
|
internalDragEnabledRef.current = enableDrag;
|
|
98
124
|
}, [enableDrag]);
|
|
99
125
|
|
|
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
126
|
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
127
|
+
// Static layout - compute positions directly without force simulation
|
|
128
|
+
const nodes = React.useMemo(() => {
|
|
129
|
+
if (!initialNodes || !initialNodes.length) return initialNodes;
|
|
130
|
+
|
|
131
|
+
const cx = width / 2;
|
|
132
|
+
const cy = height / 2;
|
|
133
|
+
|
|
134
|
+
// For force layout, use random positions but don't animate
|
|
135
|
+
if (layout === 'force') {
|
|
136
|
+
return initialNodes.map((n: any) => ({
|
|
137
|
+
...n,
|
|
138
|
+
x: Math.random() * width,
|
|
139
|
+
y: Math.random() * height,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// For circular layout, arrange in a circle
|
|
144
|
+
if (layout === 'circular') {
|
|
145
|
+
const radius = Math.min(width, height) * 0.35;
|
|
146
|
+
return initialNodes.map((n: any, i: number) => ({
|
|
147
|
+
...n,
|
|
148
|
+
x: cx + Math.cos((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
149
|
+
y: cy + Math.sin((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// For hierarchical layout, arrange in a grid
|
|
154
|
+
if (layout === 'hierarchical') {
|
|
155
|
+
const cols = Math.ceil(Math.sqrt(initialNodes.length));
|
|
156
|
+
const spacingX = width / (cols + 1);
|
|
157
|
+
const spacingY = height / (Math.ceil(initialNodes.length / cols) + 1);
|
|
158
|
+
return initialNodes.map((n: any, i: number) => ({
|
|
159
|
+
...n,
|
|
160
|
+
x: spacingX * ((i % cols) + 1),
|
|
161
|
+
y: spacingY * (Math.floor(i / cols) + 1),
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return initialNodes;
|
|
166
|
+
}, [initialNodes, width, height, layout]);
|
|
167
|
+
|
|
168
|
+
// Static links - just use initial links
|
|
169
|
+
const links = initialLinks;
|
|
170
|
+
|
|
171
|
+
// No force simulation - static layout only
|
|
172
|
+
const restart = React.useCallback(() => {
|
|
173
|
+
// No-op for static layout
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
const stop = React.useCallback(() => {
|
|
177
|
+
// No-op for static layout
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const setForcesEnabled = React.useCallback((_enabled: boolean) => {
|
|
181
|
+
// No-op for static layout
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
// Remove package bounds effect - boundary packing disabled for faster convergence
|
|
185
|
+
|
|
186
|
+
// Apply layout-specific positioning when layout changes
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!nodes || nodes.length === 0) return;
|
|
189
|
+
|
|
190
|
+
const applyLayout = () => {
|
|
191
|
+
const cx = width / 2;
|
|
192
|
+
const cy = height / 2;
|
|
193
|
+
|
|
194
|
+
if (layout === 'circular') {
|
|
195
|
+
// Place all nodes in a circle
|
|
196
|
+
const radius = Math.min(width, height) * 0.35;
|
|
197
|
+
nodes.forEach((node, i) => {
|
|
198
|
+
const angle = (2 * Math.PI * i) / nodes.length;
|
|
199
|
+
node.fx = cx + Math.cos(angle) * radius;
|
|
200
|
+
node.fy = cy + Math.sin(angle) * radius;
|
|
172
201
|
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 };
|
|
202
|
+
} else if (layout === 'hierarchical') {
|
|
203
|
+
// Place packages in rows, files within packages in columns
|
|
204
|
+
const groups = new Map<string, typeof nodes>();
|
|
205
|
+
nodes.forEach((n: any) => {
|
|
206
|
+
const key = n.packageGroup || n.group || 'root';
|
|
207
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
208
|
+
groups.get(key)!.push(n);
|
|
205
209
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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, {
|
|
232
|
-
width,
|
|
233
|
-
height,
|
|
234
|
-
chargeStrength: manualLayout ? 0 : undefined,
|
|
235
|
-
onTick,
|
|
236
|
-
...simulationOptions,
|
|
237
|
-
});
|
|
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
|
-
}
|
|
210
|
+
|
|
211
|
+
const groupArray = Array.from(groups.entries());
|
|
212
|
+
const cols = Math.ceil(Math.sqrt(groupArray.length));
|
|
213
|
+
const groupSpacingX = width * 0.8 / cols;
|
|
214
|
+
const groupSpacingY = height * 0.8 / Math.ceil(groupArray.length / cols);
|
|
215
|
+
|
|
216
|
+
groupArray.forEach(([groupKey, groupNodes], gi) => {
|
|
217
|
+
const col = gi % cols;
|
|
218
|
+
const row = Math.floor(gi / cols);
|
|
219
|
+
const groupX = (col + 0.5) * groupSpacingX;
|
|
220
|
+
const groupY = (row + 0.5) * groupSpacingY;
|
|
221
|
+
|
|
222
|
+
// Place group nodes in a small circle within their area
|
|
223
|
+
if (groupKey.startsWith('pkg:') || groupKey === groupKey) {
|
|
224
|
+
groupNodes.forEach((n, ni) => {
|
|
225
|
+
const angle = (2 * Math.PI * ni) / groupNodes.length;
|
|
226
|
+
const r = Math.min(80, 20 + groupNodes.length * 8);
|
|
227
|
+
n.fx = groupX + Math.cos(angle) * r;
|
|
228
|
+
n.fy = groupY + Math.sin(angle) * r;
|
|
229
|
+
});
|
|
282
230
|
}
|
|
283
|
-
}
|
|
284
|
-
comps.push(comp);
|
|
231
|
+
});
|
|
285
232
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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]);
|
|
233
|
+
// 'force' layout - just restart with default behavior (no fx/fy set)
|
|
234
|
+
|
|
235
|
+
try { restart(); } catch (e) { /* ignore */ }
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
applyLayout();
|
|
239
|
+
}, [layout, nodes, width, height, restart]);
|
|
332
240
|
|
|
333
241
|
// If manual layout is enabled or any nodes are pinned, disable forces
|
|
334
242
|
useEffect(() => {
|
|
@@ -418,8 +326,14 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
418
326
|
setDragMode: (enabled: boolean) => {
|
|
419
327
|
internalDragEnabledRef.current = enabled;
|
|
420
328
|
},
|
|
329
|
+
|
|
330
|
+
setLayout: (newLayout: LayoutType) => {
|
|
331
|
+
handleLayoutChange(newLayout);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
getLayout: () => layout,
|
|
421
335
|
}),
|
|
422
|
-
[nodes, pinnedNodes, restart, width, height]
|
|
336
|
+
[nodes, pinnedNodes, restart, width, height, layout, handleLayoutChange]
|
|
423
337
|
);
|
|
424
338
|
|
|
425
339
|
// Notify parent when manual layout mode changes (uses the prop so it's not unused)
|