@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/components",
3
- "version": "0.1.6",
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.7"
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 extends SimulationNode {
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 extends SimulationLink {
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
- // --- 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) };
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
- // 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 };
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
- 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, {
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
- 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]);
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)
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ export {
48
48
  type GraphNode,
49
49
  type GraphLink,
50
50
  type ForceDirectedGraphProps,
51
+ type ForceDirectedGraphHandle,
52
+ type LayoutType,
51
53
  } from './charts/ForceDirectedGraph';
52
54
  export {
53
55
  GraphControls,