@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.
@@ -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.forceCollide<SimulationNode>().radius(collisionRadius).strength(collisionStrength)
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
 
package/src/index.ts CHANGED
@@ -49,3 +49,7 @@ export {
49
49
  type GraphLink,
50
50
  type ForceDirectedGraphProps,
51
51
  } from './charts/ForceDirectedGraph';
52
+ export {
53
+ GraphControls,
54
+ type GraphControlsProps,
55
+ } from './charts/GraphControls';