@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
|
|
4
|
+
export interface GraphControlsProps {
|
|
5
|
+
/**
|
|
6
|
+
* Whether dragging is enabled
|
|
7
|
+
*/
|
|
8
|
+
dragEnabled?: boolean;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Callback to toggle drag mode
|
|
12
|
+
*/
|
|
13
|
+
onDragToggle?: (enabled: boolean) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether manual layout mode is enabled
|
|
17
|
+
*/
|
|
18
|
+
manualLayout?: boolean;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Callback to toggle manual layout mode
|
|
22
|
+
*/
|
|
23
|
+
onManualLayoutToggle?: (enabled: boolean) => void;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Callback to pin all nodes
|
|
27
|
+
*/
|
|
28
|
+
onPinAll?: () => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Callback to unpin all nodes
|
|
32
|
+
*/
|
|
33
|
+
onUnpinAll?: () => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Callback to center/reset the view
|
|
37
|
+
*/
|
|
38
|
+
onReset?: () => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Callback to fit all nodes in view
|
|
42
|
+
*/
|
|
43
|
+
onFitView?: () => void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Number of pinned nodes
|
|
47
|
+
*/
|
|
48
|
+
pinnedCount?: number;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Total number of nodes
|
|
52
|
+
*/
|
|
53
|
+
totalNodes?: number;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether to show the controls
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
visible?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Position of the controls
|
|
63
|
+
* @default "top-left"
|
|
64
|
+
*/
|
|
65
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Additional CSS classes
|
|
69
|
+
*/
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* GraphControls: Floating toolbar for manipulating graph layout and dragging
|
|
75
|
+
* Provides controls for toggling drag mode, manual layout, pinning nodes, and resetting the view
|
|
76
|
+
*/
|
|
77
|
+
export const GraphControls: React.FC<GraphControlsProps> = ({
|
|
78
|
+
dragEnabled = true,
|
|
79
|
+
onDragToggle,
|
|
80
|
+
manualLayout = false,
|
|
81
|
+
onManualLayoutToggle,
|
|
82
|
+
onPinAll,
|
|
83
|
+
onUnpinAll,
|
|
84
|
+
onReset,
|
|
85
|
+
onFitView,
|
|
86
|
+
pinnedCount = 0,
|
|
87
|
+
totalNodes = 0,
|
|
88
|
+
visible = true,
|
|
89
|
+
position = 'top-left',
|
|
90
|
+
className,
|
|
91
|
+
}) => {
|
|
92
|
+
|
|
93
|
+
if (!visible) return null;
|
|
94
|
+
|
|
95
|
+
const positionClasses: Record<string, string> = {
|
|
96
|
+
'top-left': 'top-4 left-4',
|
|
97
|
+
'top-right': 'top-4 right-4',
|
|
98
|
+
'bottom-left': 'bottom-4 left-4',
|
|
99
|
+
'bottom-right': 'bottom-4 right-4',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const ControlButton: React.FC<{
|
|
103
|
+
onClick: () => void;
|
|
104
|
+
active?: boolean;
|
|
105
|
+
icon: string;
|
|
106
|
+
label: string;
|
|
107
|
+
disabled?: boolean;
|
|
108
|
+
}> = ({ onClick, active = false, icon, label, disabled = false }) => (
|
|
109
|
+
<div className="relative group">
|
|
110
|
+
<button
|
|
111
|
+
onClick={onClick}
|
|
112
|
+
disabled={disabled}
|
|
113
|
+
className={cn(
|
|
114
|
+
'p-2 rounded-lg transition-all duration-200',
|
|
115
|
+
active
|
|
116
|
+
? 'bg-blue-500 text-white shadow-md hover:bg-blue-600'
|
|
117
|
+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
|
118
|
+
disabled && 'opacity-50 cursor-not-allowed hover:bg-gray-100',
|
|
119
|
+
'dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:active:bg-blue-600'
|
|
120
|
+
)}
|
|
121
|
+
title={label}
|
|
122
|
+
>
|
|
123
|
+
<span className="text-lg">{icon}</span>
|
|
124
|
+
</button>
|
|
125
|
+
<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">
|
|
126
|
+
{label}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
'fixed z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-2 border border-gray-200 dark:border-gray-700',
|
|
135
|
+
positionClasses[position],
|
|
136
|
+
className
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex flex-col gap-2">
|
|
140
|
+
{/* Drag Mode Toggle */}
|
|
141
|
+
<ControlButton
|
|
142
|
+
onClick={() => onDragToggle?.(!dragEnabled)}
|
|
143
|
+
active={dragEnabled}
|
|
144
|
+
icon="✋"
|
|
145
|
+
label={dragEnabled ? 'Drag enabled' : 'Drag disabled'}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
{/* Manual Layout Toggle */}
|
|
149
|
+
<ControlButton
|
|
150
|
+
onClick={() => onManualLayoutToggle?.(!manualLayout)}
|
|
151
|
+
active={manualLayout}
|
|
152
|
+
icon="🔧"
|
|
153
|
+
label={manualLayout ? 'Manual layout: ON (drag freely)' : 'Manual layout: OFF (forces active)'}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
{/* Divider */}
|
|
157
|
+
<div className="w-8 h-px bg-gray-300 dark:bg-gray-600 mx-auto my-1" />
|
|
158
|
+
|
|
159
|
+
{/* Pin/Unpin Controls */}
|
|
160
|
+
<div className="flex gap-1">
|
|
161
|
+
<ControlButton
|
|
162
|
+
onClick={() => onPinAll?.()}
|
|
163
|
+
disabled={totalNodes === 0}
|
|
164
|
+
icon="📌"
|
|
165
|
+
label={`Pin all nodes (${totalNodes})`}
|
|
166
|
+
/>
|
|
167
|
+
<ControlButton
|
|
168
|
+
onClick={() => onUnpinAll?.()}
|
|
169
|
+
disabled={pinnedCount === 0}
|
|
170
|
+
icon="📍"
|
|
171
|
+
label={`Unpin all (${pinnedCount} pinned)`}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Divider */}
|
|
176
|
+
<div className="w-8 h-px bg-gray-300 dark:bg-gray-600 mx-auto my-1" />
|
|
177
|
+
|
|
178
|
+
{/* View Controls */}
|
|
179
|
+
<ControlButton
|
|
180
|
+
onClick={() => onFitView?.()}
|
|
181
|
+
disabled={totalNodes === 0}
|
|
182
|
+
icon="🎯"
|
|
183
|
+
label="Fit all nodes in view"
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
<ControlButton
|
|
187
|
+
onClick={() => onReset?.()}
|
|
188
|
+
disabled={totalNodes === 0}
|
|
189
|
+
icon="↺"
|
|
190
|
+
label="Reset to auto-layout"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Info Panel */}
|
|
195
|
+
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-400">
|
|
196
|
+
<div className="whitespace-nowrap">
|
|
197
|
+
<strong>Nodes:</strong> {totalNodes}
|
|
198
|
+
</div>
|
|
199
|
+
{pinnedCount > 0 && (
|
|
200
|
+
<div className="whitespace-nowrap">
|
|
201
|
+
<strong>Pinned:</strong> {pinnedCount}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
<div className="mt-2 text-gray-500 dark:text-gray-500 leading-snug">
|
|
205
|
+
<strong>Tips:</strong>
|
|
206
|
+
<ul className="mt-1 ml-1 space-y-0.5">
|
|
207
|
+
<li>• Drag nodes to reposition</li>
|
|
208
|
+
<li>• Double-click to pin/unpin</li>
|
|
209
|
+
<li>• Double-click canvas to unpin all</li>
|
|
210
|
+
<li>• Scroll to zoom</li>
|
|
211
|
+
</ul>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
GraphControls.displayName = 'GraphControls';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { GraphLink, GraphNode } from './ForceDirectedGraph';
|
|
3
|
+
|
|
4
|
+
export interface LinkItemProps {
|
|
5
|
+
link: GraphLink;
|
|
6
|
+
onClick?: (l: GraphLink) => void;
|
|
7
|
+
defaultWidth?: number;
|
|
8
|
+
showLabel?: boolean;
|
|
9
|
+
nodes?: GraphNode[]; // Optional nodes array to resolve string IDs to node objects
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const LinkItem: React.FC<LinkItemProps> = ({ link, onClick, defaultWidth, showLabel = true, nodes = [] }) => {
|
|
13
|
+
const src = (link.source as any)?.id ?? (typeof link.source === 'string' ? link.source : undefined);
|
|
14
|
+
const tgt = (link.target as any)?.id ?? (typeof link.target === 'string' ? link.target : undefined);
|
|
15
|
+
|
|
16
|
+
// Helper to get node position from source/target (which could be node object or string ID)
|
|
17
|
+
const getNodePosition = (nodeOrId: string | GraphNode): { x: number; y: number } | null => {
|
|
18
|
+
if (typeof nodeOrId === 'object' && nodeOrId !== null) {
|
|
19
|
+
// It's a node object
|
|
20
|
+
const node = nodeOrId as GraphNode;
|
|
21
|
+
return { x: node.x ?? 0, y: node.y ?? 0 };
|
|
22
|
+
} else if (typeof nodeOrId === 'string') {
|
|
23
|
+
// It's a string ID, try to find in nodes array
|
|
24
|
+
const found = nodes.find(n => n.id === nodeOrId);
|
|
25
|
+
if (found) return { x: found.x ?? 0, y: found.y ?? 0 };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sourcePos = getNodePosition(link.source);
|
|
31
|
+
const targetPos = getNodePosition(link.target);
|
|
32
|
+
|
|
33
|
+
// If we can't get positions, render nothing (or a placeholder)
|
|
34
|
+
if (!sourcePos || !targetPos) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Calculate midpoint for label positioning
|
|
39
|
+
const midX = (sourcePos.x + targetPos.x) / 2;
|
|
40
|
+
const midY = (sourcePos.y + targetPos.y) / 2;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<g>
|
|
44
|
+
<line
|
|
45
|
+
x1={sourcePos.x}
|
|
46
|
+
y1={sourcePos.y}
|
|
47
|
+
x2={targetPos.x}
|
|
48
|
+
y2={targetPos.y}
|
|
49
|
+
data-source={src}
|
|
50
|
+
data-target={tgt}
|
|
51
|
+
stroke={link.color}
|
|
52
|
+
strokeWidth={link.width ?? defaultWidth ?? 1}
|
|
53
|
+
opacity={0.6}
|
|
54
|
+
className="cursor-pointer transition-opacity hover:opacity-100"
|
|
55
|
+
onClick={() => onClick?.(link)}
|
|
56
|
+
/>
|
|
57
|
+
{showLabel && link.label && (
|
|
58
|
+
<text
|
|
59
|
+
x={midX}
|
|
60
|
+
y={midY}
|
|
61
|
+
fill="#666"
|
|
62
|
+
fontSize="10"
|
|
63
|
+
textAnchor="middle"
|
|
64
|
+
dominantBaseline="middle"
|
|
65
|
+
pointerEvents="none"
|
|
66
|
+
>
|
|
67
|
+
{link.label}
|
|
68
|
+
</text>
|
|
69
|
+
)}
|
|
70
|
+
</g>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default LinkItem;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { GraphNode } from './ForceDirectedGraph';
|
|
3
|
+
|
|
4
|
+
export interface NodeItemProps {
|
|
5
|
+
node: GraphNode;
|
|
6
|
+
isSelected: boolean;
|
|
7
|
+
isHovered: boolean;
|
|
8
|
+
pinned: boolean;
|
|
9
|
+
defaultNodeSize: number;
|
|
10
|
+
defaultNodeColor: string;
|
|
11
|
+
showLabel?: boolean;
|
|
12
|
+
onClick?: (n: GraphNode) => void;
|
|
13
|
+
onDoubleClick?: (e: React.MouseEvent, n: GraphNode) => void;
|
|
14
|
+
onMouseEnter?: (n: GraphNode) => void;
|
|
15
|
+
onMouseLeave?: () => void;
|
|
16
|
+
onMouseDown?: (e: React.MouseEvent, n: GraphNode) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const NodeItem: React.FC<NodeItemProps> = ({
|
|
20
|
+
node,
|
|
21
|
+
isSelected,
|
|
22
|
+
isHovered,
|
|
23
|
+
pinned,
|
|
24
|
+
defaultNodeSize,
|
|
25
|
+
defaultNodeColor,
|
|
26
|
+
showLabel = true,
|
|
27
|
+
onClick,
|
|
28
|
+
onDoubleClick,
|
|
29
|
+
onMouseEnter,
|
|
30
|
+
onMouseLeave,
|
|
31
|
+
onMouseDown,
|
|
32
|
+
}) => {
|
|
33
|
+
const nodeSize = node.size || defaultNodeSize;
|
|
34
|
+
const nodeColor = node.color || defaultNodeColor;
|
|
35
|
+
|
|
36
|
+
const x = node.x ?? 0;
|
|
37
|
+
const y = node.y ?? 0;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<g
|
|
41
|
+
key={node.id}
|
|
42
|
+
className="cursor-pointer node"
|
|
43
|
+
data-id={node.id}
|
|
44
|
+
transform={`translate(${x},${y})`}
|
|
45
|
+
onClick={() => onClick?.(node)}
|
|
46
|
+
onDoubleClick={(e) => onDoubleClick?.(e, node)}
|
|
47
|
+
onMouseEnter={() => onMouseEnter?.(node)}
|
|
48
|
+
onMouseLeave={() => onMouseLeave?.()}
|
|
49
|
+
onMouseDown={(e) => onMouseDown?.(e, node)}
|
|
50
|
+
>
|
|
51
|
+
<circle
|
|
52
|
+
r={nodeSize}
|
|
53
|
+
fill={nodeColor}
|
|
54
|
+
stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
|
|
55
|
+
strokeWidth={pinned ? 3 : isSelected ? 2.5 : isHovered ? 2 : 1.5}
|
|
56
|
+
opacity={isHovered || isSelected ? 1 : 0.9}
|
|
57
|
+
/>
|
|
58
|
+
{pinned && (
|
|
59
|
+
<circle r={nodeSize + 4} fill="none" stroke="#ff6b6b" strokeWidth={1} opacity={0.5} className="pointer-events-none" />
|
|
60
|
+
)}
|
|
61
|
+
{showLabel && node.label && (
|
|
62
|
+
<text y={nodeSize + 15} fill="#333" fontSize="12" textAnchor="middle" dominantBaseline="middle" pointerEvents="none" className="select-none">
|
|
63
|
+
{node.label}
|
|
64
|
+
</text>
|
|
65
|
+
)}
|
|
66
|
+
</g>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default NodeItem;
|