@aiready/components 0.13.18 → 0.13.19

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.13.18",
3
+ "version": "0.13.19",
4
4
  "description": "Unified shared components library (UI, charts, hooks, utilities) for AIReady",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -128,7 +128,7 @@
128
128
  "framer-motion": "^12.35.0",
129
129
  "lucide-react": "^0.577.0",
130
130
  "tailwind-merge": "^3.0.0",
131
- "@aiready/core": "0.23.19"
131
+ "@aiready/core": "0.23.20"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@testing-library/jest-dom": "^6.6.5",
@@ -136,6 +136,7 @@
136
136
  "@types/d3": "^7.4.3",
137
137
  "@types/react": "^19.0.6",
138
138
  "@types/react-dom": "^19.0.3",
139
+ "jsdom": "^29.0.1",
139
140
  "tailwindcss": "^4.1.14",
140
141
  "tsup": "^8.3.5",
141
142
  "typescript": "^5.7.2",
@@ -1,509 +1,12 @@
1
- import React, {
2
- useCallback,
3
- useEffect,
4
- useRef,
5
- useState,
6
- forwardRef,
7
- useImperativeHandle,
8
- } from 'react';
9
- import * as d3 from 'd3';
10
- import { cn } from '../utils/cn';
11
- import NodeItem from './NodeItem';
12
- import LinkItem from './LinkItem';
13
- import { PackageBoundaries } from './PackageBoundaries';
14
- import {
15
- applyCircularLayout,
16
- applyHierarchicalLayout,
17
- applyInitialForceLayout,
18
- } from './layout-utils';
19
- import {
20
- DEFAULT_NODE_COLOR,
21
- DEFAULT_NODE_SIZE,
22
- DEFAULT_LINK_COLOR,
23
- DEFAULT_LINK_WIDTH,
24
- FIT_VIEW_PADDING,
25
- TRANSITION_DURATION_MS,
26
- } from './constants';
27
- import { useGraphZoom, useWindowDrag } from './hooks';
28
-
29
- import { GraphNode, GraphLink, LayoutType } from './types';
30
- export type { GraphNode, GraphLink, LayoutType };
31
-
32
- /**
33
- * Handle for imperative actions on the ForceDirectedGraph.
34
- */
35
- export interface ForceDirectedGraphHandle {
36
- /** Pins all nodes to their current positions. */
37
- pinAll: () => void;
38
- /** Unpins all nodes, allowing them to move freely in the simulation. */
39
- unpinAll: () => void;
40
- /** Resets the layout by unpinning all nodes and restarting the simulation. */
41
- resetLayout: () => void;
42
- /** Rescales and re-centers the view to fit all nodes. */
43
- fitView: () => void;
44
- /** Returns the IDs of all currently pinned nodes. */
45
- getPinnedNodes: () => string[];
46
- /**
47
- * Enable or disable drag mode for nodes.
48
- * @param enabled - When true, nodes can be dragged; when false, dragging is disabled
49
- */
50
- setDragMode: (enabled: boolean) => void;
51
- /** Sets the current layout type. */
52
- setLayout: (layout: LayoutType) => void;
53
- /** Gets the current layout type. */
54
- getLayout: () => LayoutType;
55
- }
56
-
57
- /**
58
- * Props for the ForceDirectedGraph component.
59
- */
60
- export interface ForceDirectedGraphProps {
61
- /** Array of node objects to render. */
62
- nodes: GraphNode[];
63
- /** Array of link objects to render. */
64
- links: GraphLink[];
65
- /** Width of the SVG canvas. */
66
- width: number;
67
- /** Height of the SVG canvas. */
68
- height: number;
69
- /** Whether to enable zoom and pan interactions. */
70
- enableZoom?: boolean;
71
- /** Whether to enable node dragging. */
72
- enableDrag?: boolean;
73
- /** Callback fired when a node is clicked. */
74
- onNodeClick?: (node: GraphNode) => void;
75
- /** Callback fired when a node is hovered. */
76
- onNodeHover?: (node: GraphNode | null) => void;
77
- /** Callback fired when a link is clicked. */
78
- onLinkClick?: (link: GraphLink) => void;
79
- /** ID of the currently selected node. */
80
- selectedNodeId?: string;
81
- /** ID of the currently hovered node. */
82
- hoveredNodeId?: string;
83
- /** Default fallback color for nodes. */
84
- defaultNodeColor?: string;
85
- /** Default fallback size for nodes. */
86
- defaultNodeSize?: number;
87
- /** Default fallback color for links. */
88
- defaultLinkColor?: string;
89
- /** Default fallback width for links. */
90
- defaultLinkWidth?: number;
91
- /** Whether to show labels on nodes. */
92
- showNodeLabels?: boolean;
93
- /** Whether to show labels on links. */
94
- showLinkLabels?: boolean;
95
- /** Additional CSS classes for the SVG element. */
96
- className?: string;
97
- /** Whether manual layout mode is active. */
98
- manualLayout?: boolean;
99
- /** Callback fired when manual layout mode changes. */
100
- onManualLayoutChange?: (enabled: boolean) => void;
101
- /** Optional bounds for package groups. */
102
- packageBounds?: Record<string, { x: number; y: number; r: number }>;
103
- /** Current layout algorithm. */
104
- layout?: LayoutType;
105
- /** Callback fired when layout changes. */
106
- onLayoutChange?: (layout: LayoutType) => void;
107
- }
108
-
109
- /**
110
- * Helper functions for graph node manipulation.
111
- * Extracted to reduce semantic duplicate patterns.
112
- */
113
-
114
- /** Pins a node to its current position (sets fx/fy to current x/y) */
115
- function pinNode(node: GraphNode): void {
116
- node.fx = node.x;
117
- node.fy = node.y;
118
- }
119
-
120
- /** Unpins a node (sets fx/fy to null) */
121
- function unpinNode(node: GraphNode): void {
122
- node.fx = null;
123
- node.fy = null;
124
- }
125
-
126
- /** Unpins all nodes - helper for bulk unpin operations */
127
- function unpinAllNodes(nodes: GraphNode[]): void {
128
- nodes.forEach(unpinNode);
129
- }
130
-
131
- /**
132
- * An interactive Force-Directed Graph component using D3.js for physics and React for rendering.
133
- *
134
- * Supports multiple layout modes (force, circular, hierarchical), pinning, zooming, and dragging.
135
- * Optimal for visualizing complex dependency networks and codebase structures.
136
- *
137
- * @lastUpdated 2026-03-18
138
- */
139
- export const ForceDirectedGraph = forwardRef<
140
- ForceDirectedGraphHandle,
141
- ForceDirectedGraphProps
142
- >(
143
- (
144
- {
145
- nodes: initialNodes,
146
- links: initialLinks,
147
- width,
148
- height,
149
- enableZoom = true,
150
- enableDrag = true,
151
- onNodeClick,
152
- onNodeHover,
153
- onLinkClick,
154
- selectedNodeId,
155
- hoveredNodeId,
156
- defaultNodeColor = DEFAULT_NODE_COLOR,
157
- defaultNodeSize = DEFAULT_NODE_SIZE,
158
- defaultLinkColor = DEFAULT_LINK_COLOR,
159
- defaultLinkWidth = DEFAULT_LINK_WIDTH,
160
- showNodeLabels = true,
161
- showLinkLabels = false,
162
- className,
163
- manualLayout = false,
164
- onManualLayoutChange,
165
- packageBounds,
166
- layout: externalLayout,
167
- onLayoutChange,
168
- },
169
- ref
170
- ) => {
171
- const svgRef = useRef<SVGSVGElement>(null);
172
- const gRef = useRef<SVGGElement>(null);
173
- const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
174
- const transformRef = useRef(transform);
175
- const dragNodeRef = useRef<GraphNode | null>(null);
176
- const dragActiveRef = useRef(false);
177
- const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
178
- const internalDragEnabledRef = useRef(enableDrag);
179
- const [layout, setLayout] = useState<LayoutType>(externalLayout || 'force');
180
-
181
- // Sync external layout prop with internal state
182
- useEffect(() => {
183
- if (externalLayout && externalLayout !== layout) {
184
- setLayout(externalLayout);
185
- }
186
- }, [externalLayout, layout]);
187
-
188
- // Handle layout change and notify parent
189
- const handleLayoutChange = useCallback(
190
- (newLayout: LayoutType) => {
191
- setLayout(newLayout);
192
- onLayoutChange?.(newLayout);
193
- },
194
- [onLayoutChange]
195
- );
196
-
197
- // Update the ref when enableDrag prop changes
198
- useEffect(() => {
199
- internalDragEnabledRef.current = enableDrag;
200
- }, [enableDrag]);
201
-
202
- // Initial positioning - delegate to layout utils
203
- const nodes = React.useMemo(() => {
204
- if (!initialNodes || !initialNodes.length) return initialNodes;
205
- const copy = initialNodes.map((n) => ({ ...n }));
206
- if (layout === 'circular') applyCircularLayout(copy, width, height);
207
- else if (layout === 'hierarchical')
208
- applyHierarchicalLayout(copy, width, height);
209
- else applyInitialForceLayout(copy, width, height);
210
- return copy;
211
- }, [initialNodes, width, height, layout]);
212
-
213
- // No force simulation - static layout only (stubs for API compatibility)
214
- const restart = React.useCallback(() => {}, []);
215
- const stop = React.useCallback(() => {}, []);
216
- const setForcesEnabled = React.useCallback((enabled?: boolean) => {
217
- void enabled;
218
- }, []);
219
-
220
- // Apply layout-specific positioning when layout changes
221
- useEffect(() => {
222
- if (!nodes || nodes.length === 0) return;
223
- if (layout === 'circular') applyCircularLayout(nodes, width, height);
224
- else if (layout === 'hierarchical')
225
- applyHierarchicalLayout(nodes, width, height);
226
-
227
- restart();
228
- }, [layout, nodes, width, height, restart]);
229
-
230
- // If manual layout is enabled or any nodes are pinned, disable forces
231
- useEffect(() => {
232
- if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
233
- else setForcesEnabled(true);
234
- }, [manualLayout, pinnedNodes, setForcesEnabled]);
235
-
236
- // Expose imperative handle for parent components
237
- useImperativeHandle(
238
- ref,
239
- () => ({
240
- pinAll: () => {
241
- const newPinned = new Set<string>();
242
- nodes.forEach((node) => {
243
- pinNode(node);
244
- newPinned.add(node.id);
245
- });
246
- setPinnedNodes(newPinned);
247
- restart();
248
- },
249
- unpinAll: () => {
250
- unpinAllNodes(nodes);
251
- setPinnedNodes(new Set());
252
- restart();
253
- },
254
- resetLayout: () => {
255
- unpinAllNodes(nodes);
256
- setPinnedNodes(new Set());
257
- restart();
258
- },
259
- fitView: () => {
260
- if (!svgRef.current || !nodes.length) return;
261
- let minX = Infinity,
262
- maxX = -Infinity,
263
- minY = Infinity,
264
- maxY = -Infinity;
265
- nodes.forEach((node) => {
266
- if (node.x !== undefined && node.y !== undefined) {
267
- const size = node.size || DEFAULT_NODE_SIZE;
268
- minX = Math.min(minX, node.x - size);
269
- maxX = Math.max(maxX, node.x + size);
270
- minY = Math.min(minY, node.y - size);
271
- maxY = Math.max(maxY, node.y + size);
272
- }
273
- });
274
- if (!isFinite(minX)) return;
275
- const scale = Math.min(
276
- (width - FIT_VIEW_PADDING * 2) / (maxX - minX),
277
- (height - FIT_VIEW_PADDING * 2) / (maxY - minY),
278
- 10
279
- );
280
- const x = width / 2 - ((minX + maxX) / 2) * scale;
281
- const y = height / 2 - ((minY + maxY) / 2) * scale;
282
- if (gRef.current && svgRef.current) {
283
- const svg = d3.select(svgRef.current);
284
- const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
285
- svg
286
- .transition()
287
- .duration(TRANSITION_DURATION_MS)
288
- .call((d3 as any).zoom().transform as any, newTransform);
289
- setTransform(newTransform);
290
- }
291
- },
292
- getPinnedNodes: () => Array.from(pinnedNodes),
293
- setDragMode: (enabled: boolean) => {
294
- internalDragEnabledRef.current = enabled;
295
- },
296
- setLayout: (newLayout: LayoutType) => handleLayoutChange(newLayout),
297
- getLayout: () => layout,
298
- }),
299
- [
300
- nodes,
301
- pinnedNodes,
302
- restart,
303
- width,
304
- height,
305
- layout,
306
- handleLayoutChange,
307
- setForcesEnabled,
308
- ]
309
- );
310
-
311
- // Notify parent when manual layout mode changes
312
- useEffect(() => {
313
- if (typeof onManualLayoutChange === 'function')
314
- onManualLayoutChange(manualLayout);
315
- }, [manualLayout, onManualLayoutChange]);
316
-
317
- // Use custom hooks for zoom and window-level drag
318
- useGraphZoom(svgRef, gRef, enableZoom, setTransform, transformRef);
319
- useWindowDrag(
320
- enableDrag,
321
- svgRef,
322
- transformRef,
323
- dragActiveRef,
324
- dragNodeRef,
325
- () => {
326
- setForcesEnabled(true);
327
- restart();
328
- }
329
- );
330
-
331
- // Run positioning pass when nodes/links change
332
- useEffect(() => {
333
- if (!gRef.current) return;
334
- const g = d3.select(gRef.current);
335
- g.selectAll('g.node').each(function (this: any) {
336
- const datum = d3.select(this).datum() as any;
337
- if (!datum) return;
338
- d3.select(this).attr(
339
- 'transform',
340
- `translate(${datum.x || 0},${datum.y || 0})`
341
- );
342
- });
343
- g.selectAll('line').each(function (this: any) {
344
- const l = d3.select(this).datum() as any;
345
- if (!l) return;
346
- const s: any =
347
- typeof l.source === 'object'
348
- ? l.source
349
- : nodes.find((n) => n.id === l.source) || l.source;
350
- const t: any =
351
- typeof l.target === 'object'
352
- ? l.target
353
- : nodes.find((n) => n.id === l.target) || l.target;
354
- if (!s || !t) return;
355
- d3.select(this)
356
- .attr('x1', s.x)
357
- .attr('y1', s.y)
358
- .attr('x2', t.x)
359
- .attr('y2', t.y);
360
- });
361
- }, [nodes, initialLinks]);
362
-
363
- const handleDragStart = useCallback(
364
- (event: React.MouseEvent, node: GraphNode) => {
365
- if (!enableDrag) return;
366
- event.preventDefault();
367
- event.stopPropagation();
368
- dragActiveRef.current = true;
369
- dragNodeRef.current = node;
370
- pinNode(node);
371
- setPinnedNodes((prev) => new Set([...prev, node.id]));
372
- stop();
373
- },
374
- [enableDrag, stop]
375
- );
376
-
377
- // Attach d3.drag behavior to nodes
378
- useEffect(() => {
379
- if (!gRef.current || !enableDrag) return;
380
- const g = d3.select(gRef.current);
381
- const dragBehavior = (d3 as any)
382
- .drag()
383
- .on('start', (event: any) => {
384
- const target =
385
- (event.sourceEvent && (event.sourceEvent.target as Element)) ||
386
- (event.target as Element);
387
- const grp = target.closest?.('g.node') as Element | null;
388
- const id = grp?.getAttribute('data-id');
389
- if (!id || !internalDragEnabledRef.current) return;
390
- const node = nodes.find((n) => n.id === id);
391
- if (!node) return;
392
- if (!event.active) restart();
393
- dragActiveRef.current = true;
394
- dragNodeRef.current = node;
395
- pinNode(node);
396
- setPinnedNodes((prev) => new Set([...prev, node.id]));
397
- })
398
- .on('drag', (event: any) => {
399
- if (!dragActiveRef.current || !dragNodeRef.current) return;
400
- const svg = svgRef.current;
401
- if (!svg) return;
402
- const rect = svg.getBoundingClientRect();
403
- dragNodeRef.current.fx =
404
- (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
405
- dragNodeRef.current.fy =
406
- (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
407
- })
408
- .on('end', () => {
409
- setForcesEnabled(true);
410
- restart();
411
- });
412
-
413
- g.selectAll('g.node').call(dragBehavior as any);
414
- return () => {
415
- g.selectAll('g.node').on('.drag', null as any);
416
- };
417
- }, [
418
- gRef,
419
- enableDrag,
420
- nodes,
421
- transform,
422
- restart,
423
- setForcesEnabled,
424
- internalDragEnabledRef,
425
- ]);
426
-
427
- const handleNodeDoubleClick = useCallback(
428
- (event: React.MouseEvent, node: GraphNode) => {
429
- event.stopPropagation();
430
- if (!enableDrag) return;
431
- if (node.fx === null || node.fx === undefined) {
432
- pinNode(node);
433
- setPinnedNodes((prev) => new Set([...prev, node.id]));
434
- } else {
435
- unpinNode(node);
436
- setPinnedNodes((prev) => {
437
- const next = new Set(prev);
438
- next.delete(node.id);
439
- return next;
440
- });
441
- }
442
- restart();
443
- },
444
- [enableDrag, restart]
445
- );
446
-
447
- return (
448
- <svg
449
- ref={svgRef}
450
- width={width}
451
- height={height}
452
- className={cn('bg-white dark:bg-gray-900', className)}
453
- onDoubleClick={() => {
454
- unpinAllNodes(nodes);
455
- setPinnedNodes(new Set());
456
- restart();
457
- }}
458
- >
459
- <defs>
460
- <marker
461
- id="arrow"
462
- viewBox="0 0 10 10"
463
- refX="20"
464
- refY="5"
465
- markerWidth="6"
466
- markerHeight="6"
467
- orient="auto"
468
- >
469
- <path d="M 0 0 L 10 5 L 0 10 z" fill={defaultLinkColor} />
470
- </marker>
471
- </defs>
472
-
473
- <g ref={gRef}>
474
- {initialLinks.map((link, i) => (
475
- <LinkItem
476
- key={`link-${i}`}
477
- link={link as GraphLink}
478
- onClick={onLinkClick}
479
- defaultWidth={defaultLinkWidth}
480
- showLabel={showLinkLabels}
481
- nodes={nodes}
482
- />
483
- ))}
484
-
485
- {nodes.map((node) => (
486
- <NodeItem
487
- key={node.id}
488
- node={node}
489
- isSelected={selectedNodeId === node.id}
490
- isHovered={hoveredNodeId === node.id}
491
- pinned={pinnedNodes.has(node.id)}
492
- defaultNodeSize={defaultNodeSize}
493
- defaultNodeColor={defaultNodeColor}
494
- showLabel={showNodeLabels}
495
- onClick={onNodeClick}
496
- onDoubleClick={handleNodeDoubleClick}
497
- onMouseEnter={(n) => onNodeHover?.(n)}
498
- onMouseLeave={() => onNodeHover?.(null)}
499
- onMouseDown={handleDragStart}
500
- />
501
- ))}
502
- <PackageBoundaries packageBounds={packageBounds || {}} />
503
- </g>
504
- </svg>
505
- );
506
- }
507
- );
508
-
509
- ForceDirectedGraph.displayName = 'ForceDirectedGraph';
1
+ // Re-export from the new modular structure for backward compatibility
2
+ export {
3
+ ForceDirectedGraph,
4
+ type ForceDirectedGraphHandle,
5
+ type ForceDirectedGraphProps,
6
+ type GraphNode,
7
+ type GraphLink,
8
+ type LayoutType,
9
+ } from './force-directed';
10
+
11
+ // Default export for backward compatibility
12
+ export { ForceDirectedGraph as default } from './force-directed';
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { GraphLink, GraphNode } from './types';
2
+ import type { GraphLink, GraphNode } from './force-directed/types';
3
3
 
4
4
  export interface LinkItemProps {
5
5
  link: GraphLink;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { GraphNode } from './types';
2
+ import type { GraphNode } from './force-directed/types';
3
3
 
4
4
  export interface NodeItemProps {
5
5
  node: GraphNode;
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export interface ControlButtonProps {
5
+ onClick: () => void;
6
+ active?: boolean;
7
+ icon: string;
8
+ label: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export const ControlButton: React.FC<ControlButtonProps> = ({
13
+ onClick,
14
+ active = false,
15
+ icon,
16
+ label,
17
+ disabled = false,
18
+ }) => (
19
+ <div className="relative group">
20
+ <button
21
+ onClick={onClick}
22
+ disabled={disabled}
23
+ className={cn(
24
+ 'p-2 rounded-lg transition-all duration-200',
25
+ active
26
+ ? 'bg-blue-500 text-white shadow-md hover:bg-blue-600'
27
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200',
28
+ disabled && 'opacity-50 cursor-not-allowed hover:bg-gray-100',
29
+ 'dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:active:bg-blue-600'
30
+ )}
31
+ title={label}
32
+ >
33
+ <span className="text-lg">{icon}</span>
34
+ </button>
35
+ <div className="absolute left-full ml-2 px-2 py-1 bg-gray-900 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-50">
36
+ {label}
37
+ </div>
38
+ </div>
39
+ );