@aiready/components 0.1.0 → 0.1.3
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 +17 -2
- package/dist/charts/ForceDirectedGraph.js +518 -196
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/hooks/useForceSimulation.d.ts +4 -1
- package/dist/hooks/useForceSimulation.js +34 -5
- package/dist/hooks/useForceSimulation.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +651 -194
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/smoke.test.ts +5 -0
- package/src/charts/ForceDirectedGraph.tsx +441 -58
- package/src/charts/GraphControls.tsx +218 -0
- package/src/hooks/useForceSimulation.ts +49 -3
- 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';
|
|
@@ -70,6 +70,11 @@ export interface ForceSimulationOptions {
|
|
|
70
70
|
* @default 0.4
|
|
71
71
|
*/
|
|
72
72
|
velocityDecay?: number;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Optional tick callback invoked on each simulation tick with current nodes/links and the simulation instance
|
|
76
|
+
*/
|
|
77
|
+
onTick?: (nodes: SimulationNode[], links: SimulationLink[], sim: d3.Simulation<SimulationNode, SimulationLink>) => void;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
export interface UseForceSimulationReturn {
|
|
@@ -168,7 +173,7 @@ export function useForceSimulation(
|
|
|
168
173
|
initialNodes: SimulationNode[],
|
|
169
174
|
initialLinks: SimulationLink[],
|
|
170
175
|
options: ForceSimulationOptions
|
|
171
|
-
): UseForceSimulationReturn {
|
|
176
|
+
): UseForceSimulationReturn & { setForcesEnabled: (enabled: boolean) => void } {
|
|
172
177
|
const {
|
|
173
178
|
chargeStrength = -300,
|
|
174
179
|
linkDistance = 100,
|
|
@@ -180,6 +185,7 @@ export function useForceSimulation(
|
|
|
180
185
|
height,
|
|
181
186
|
alphaDecay = 0.0228,
|
|
182
187
|
velocityDecay = 0.4,
|
|
188
|
+
onTick,
|
|
183
189
|
} = options;
|
|
184
190
|
|
|
185
191
|
const [nodes, setNodes] = useState<SimulationNode[]>(initialNodes);
|
|
@@ -202,14 +208,21 @@ export function useForceSimulation(
|
|
|
202
208
|
d3
|
|
203
209
|
.forceLink<SimulationNode, SimulationLink>(linksCopy)
|
|
204
210
|
.id((d) => d.id)
|
|
205
|
-
.distance(linkDistance)
|
|
211
|
+
.distance((d: any) => (d && d.distance != null ? d.distance : linkDistance))
|
|
206
212
|
.strength(linkStrength)
|
|
207
213
|
)
|
|
208
214
|
.force('charge', d3.forceManyBody().strength(chargeStrength))
|
|
209
215
|
.force('center', d3.forceCenter(width / 2, height / 2).strength(centerStrength))
|
|
210
216
|
.force(
|
|
211
217
|
'collision',
|
|
212
|
-
d3
|
|
218
|
+
d3
|
|
219
|
+
.forceCollide<SimulationNode>()
|
|
220
|
+
.radius((d: any) => {
|
|
221
|
+
// Use node-specific size when available and add configured padding to ensure minimum spacing
|
|
222
|
+
const nodeSize = (d && d.size) ? d.size : 10;
|
|
223
|
+
return nodeSize + collisionRadius;
|
|
224
|
+
})
|
|
225
|
+
.strength(collisionStrength)
|
|
213
226
|
)
|
|
214
227
|
.alphaDecay(alphaDecay)
|
|
215
228
|
.velocityDecay(velocityDecay);
|
|
@@ -218,6 +231,11 @@ export function useForceSimulation(
|
|
|
218
231
|
|
|
219
232
|
// Update state on each tick
|
|
220
233
|
simulation.on('tick', () => {
|
|
234
|
+
try {
|
|
235
|
+
if (typeof onTick === 'function') onTick(nodesCopy, linksCopy, simulation);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// ignore user tick errors
|
|
238
|
+
}
|
|
221
239
|
setNodes([...nodesCopy]);
|
|
222
240
|
setLinks([...linksCopy]);
|
|
223
241
|
setAlpha(simulation.alpha());
|
|
@@ -245,6 +263,7 @@ export function useForceSimulation(
|
|
|
245
263
|
height,
|
|
246
264
|
alphaDecay,
|
|
247
265
|
velocityDecay,
|
|
266
|
+
onTick,
|
|
248
267
|
]);
|
|
249
268
|
|
|
250
269
|
const restart = () => {
|
|
@@ -261,6 +280,32 @@ export function useForceSimulation(
|
|
|
261
280
|
}
|
|
262
281
|
};
|
|
263
282
|
|
|
283
|
+
const originalForcesRef = useRef({ charge: chargeStrength, link: linkStrength, collision: collisionStrength });
|
|
284
|
+
const forcesEnabledRef = useRef(true);
|
|
285
|
+
|
|
286
|
+
const setForcesEnabled = (enabled: boolean) => {
|
|
287
|
+
const sim = simulationRef.current;
|
|
288
|
+
if (!sim) return;
|
|
289
|
+
// avoid repeated updates
|
|
290
|
+
if (forcesEnabledRef.current === enabled) return;
|
|
291
|
+
forcesEnabledRef.current = enabled;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Only toggle charge and link forces to avoid collapse; keep collision/centering
|
|
295
|
+
const charge: any = sim.force('charge');
|
|
296
|
+
if (charge && typeof charge.strength === 'function') {
|
|
297
|
+
charge.strength(enabled ? originalForcesRef.current.charge : 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const link: any = sim.force('link');
|
|
301
|
+
if (link && typeof link.strength === 'function') {
|
|
302
|
+
link.strength(enabled ? originalForcesRef.current.link : 0);
|
|
303
|
+
}
|
|
304
|
+
} catch (e) {
|
|
305
|
+
// ignore
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
264
309
|
return {
|
|
265
310
|
nodes,
|
|
266
311
|
links,
|
|
@@ -268,6 +313,7 @@ export function useForceSimulation(
|
|
|
268
313
|
stop,
|
|
269
314
|
isRunning,
|
|
270
315
|
alpha,
|
|
316
|
+
setForcesEnabled,
|
|
271
317
|
};
|
|
272
318
|
}
|
|
273
319
|
|