@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.
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
2
2
  import * as d3 from 'd3';
3
3
  import {
4
4
  useForceSimulation,
@@ -22,6 +22,38 @@ export interface GraphLink extends SimulationLink {
22
22
  label?: string;
23
23
  }
24
24
 
25
+ export interface ForceDirectedGraphHandle {
26
+ /**
27
+ * Pin all nodes in place
28
+ */
29
+ pinAll: () => void;
30
+
31
+ /**
32
+ * Unpin all nodes (release constraints)
33
+ */
34
+ unpinAll: () => void;
35
+
36
+ /**
37
+ * Reset all nodes to auto-layout (unpin and restart simulation)
38
+ */
39
+ resetLayout: () => void;
40
+
41
+ /**
42
+ * Fit all nodes in the current view
43
+ */
44
+ fitView: () => void;
45
+
46
+ /**
47
+ * Get currently pinned node IDs
48
+ */
49
+ getPinnedNodes: () => string[];
50
+
51
+ /**
52
+ * Toggle dragging mode
53
+ */
54
+ setDragMode: (enabled: boolean) => void;
55
+ }
56
+
25
57
  export interface ForceDirectedGraphProps {
26
58
  /**
27
59
  * Array of nodes to display
@@ -125,40 +157,242 @@ export interface ForceDirectedGraphProps {
125
157
  * Additional CSS classes
126
158
  */
127
159
  className?: string;
160
+
161
+ /**
162
+ * Manual layout mode: disables forces, allows free dragging
163
+ * @default false
164
+ */
165
+ manualLayout?: boolean;
166
+
167
+ /**
168
+ * Callback when manual layout mode is toggled
169
+ */
170
+ onManualLayoutChange?: (enabled: boolean) => void;
171
+
172
+ /**
173
+ * Package bounds computed by the parent (pack layout): map of `pkg:group` -> {x,y,r}
174
+ */
175
+ packageBounds?: Record<string, { x: number; y: number; r: number }>;
128
176
  }
129
177
 
130
- export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
131
- nodes: initialNodes,
132
- links: initialLinks,
133
- width,
134
- height,
135
- simulationOptions,
136
- enableZoom = true,
137
- enableDrag = true,
138
- onNodeClick,
139
- onNodeHover,
140
- onLinkClick,
141
- selectedNodeId,
142
- hoveredNodeId,
143
- defaultNodeColor = '#69b3a2',
144
- defaultNodeSize = 10,
145
- defaultLinkColor = '#999',
146
- defaultLinkWidth = 1,
147
- showNodeLabels = true,
148
- showLinkLabels = false,
149
- className,
150
- }) => {
178
+ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDirectedGraphProps>(
179
+ (
180
+ {
181
+ nodes: initialNodes,
182
+ links: initialLinks,
183
+ width,
184
+ height,
185
+ simulationOptions,
186
+ enableZoom = true,
187
+ enableDrag = true,
188
+ onNodeClick,
189
+ onNodeHover,
190
+ onLinkClick,
191
+ selectedNodeId,
192
+ hoveredNodeId,
193
+ defaultNodeColor = '#69b3a2',
194
+ defaultNodeSize = 10,
195
+ defaultLinkColor = '#999',
196
+ defaultLinkWidth = 1,
197
+ showNodeLabels = true,
198
+ showLinkLabels = false,
199
+ className,
200
+ manualLayout = false,
201
+ onManualLayoutChange,
202
+ packageBounds,
203
+ },
204
+ ref
205
+ ) => {
151
206
  const svgRef = useRef<SVGSVGElement>(null);
152
207
  const gRef = useRef<SVGGElement>(null);
153
208
  const [transform, setTransform] = useState({ k: 1, x: 0, y: 0 });
209
+ const dragNodeRef = useRef<GraphNode | null>(null);
210
+ const dragActiveRef = useRef(false);
211
+ const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(new Set());
212
+ const internalDragEnabledRef = useRef(enableDrag);
213
+
214
+ // Update the ref when enableDrag prop changes
215
+ useEffect(() => {
216
+ internalDragEnabledRef.current = enableDrag;
217
+ }, [enableDrag]);
218
+
219
+ // Initialize simulation with manualLayout mode
220
+ const onTick = (nodesCopy: any[], _linksCopy: any[], _sim: any) => {
221
+ const bounds = packageBounds && Object.keys(packageBounds).length ? packageBounds : undefined;
222
+ // fallback: if parent didn't provide packageBounds, compute locally from initialNodes
223
+ let effectiveBounds = bounds;
224
+ if (!effectiveBounds) {
225
+ try {
226
+ const counts: Record<string, number> = {};
227
+ (initialNodes || []).forEach((n: any) => {
228
+ if (n && n.kind === 'file') {
229
+ const g = n.packageGroup || 'root';
230
+ counts[g] = (counts[g] || 0) + 1;
231
+ }
232
+ });
233
+ const children = Object.keys(counts).map((k) => ({ name: k, value: counts[k] }));
234
+ if (children.length > 0) {
235
+ const root = d3.hierarchy<any>({ children } as any).sum((d: any) => d.value as number);
236
+ const pack = d3.pack().size([width, height]).padding(30);
237
+ const packed = pack(root);
238
+ const map: Record<string, { x: number; y: number; r: number }> = {};
239
+ if (packed.children) {
240
+ packed.children.forEach((c: any) => {
241
+ map[`pkg:${c.data.name}`] = { x: c.x, y: c.y, r: c.r * 0.95 };
242
+ });
243
+ effectiveBounds = map;
244
+ }
245
+ }
246
+ } catch (e) {
247
+ // ignore fallback errors
248
+ }
249
+ }
250
+ if (!effectiveBounds) return;
251
+ try {
252
+ Object.values(nodesCopy).forEach((n: any) => {
253
+ if (!n) return;
254
+ // only constrain file nodes (package nodes have their own fx/fy)
255
+ if (n.kind === 'package') return;
256
+ const pkg = n.packageGroup;
257
+ if (!pkg) return;
258
+ const bound = effectiveBounds[`pkg:${pkg}`];
259
+ if (!bound) return;
260
+ const margin = (n.size || 10) + 12;
261
+ const dx = (n.x || 0) - bound.x;
262
+ const dy = (n.y || 0) - bound.y;
263
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
264
+ const maxDist = Math.max(1, bound.r - margin);
265
+ if (dist > maxDist) {
266
+ const desiredX = bound.x + dx * (maxDist / dist);
267
+ const desiredY = bound.y + dy * (maxDist / dist);
268
+ // apply a soft corrective velocity toward the desired position
269
+ const softness = 0.08;
270
+ n.vx = (n.vx || 0) + (desiredX - n.x) * softness;
271
+ n.vy = (n.vy || 0) + (desiredY - n.y) * softness;
272
+ }
273
+ });
274
+ } catch (e) {
275
+ // ignore
276
+ }
277
+ };
154
278
 
155
- // Initialize simulation
156
- const { nodes, links, restart } = useForceSimulation(initialNodes, initialLinks, {
279
+ const { nodes, links, restart, stop, setForcesEnabled } = useForceSimulation(initialNodes, initialLinks, {
157
280
  width,
158
281
  height,
282
+ chargeStrength: manualLayout ? 0 : undefined,
283
+ onTick,
159
284
  ...simulationOptions,
160
285
  });
161
286
 
287
+ // If package bounds are provided, add a tick-time clamp via the hook's onTick option
288
+ useEffect(() => {
289
+ if (!packageBounds) return;
290
+ // nothing to do here because the hook will call onTick passed in creation; we need to recreate simulation to use onTick
291
+ // So restart the simulation to pick up potential changes in node bounds.
292
+ try { restart(); } catch (e) {}
293
+ }, [packageBounds, restart]);
294
+
295
+ // If manual layout is enabled or any nodes are pinned, disable forces
296
+ useEffect(() => {
297
+ try {
298
+ if (manualLayout || pinnedNodes.size > 0) setForcesEnabled(false);
299
+ else setForcesEnabled(true);
300
+ } catch (e) {
301
+ // ignore
302
+ }
303
+ }, [manualLayout, pinnedNodes, setForcesEnabled]);
304
+
305
+ // Expose imperative handle for parent components
306
+ useImperativeHandle(
307
+ ref,
308
+ () => ({
309
+ pinAll: () => {
310
+ const newPinned = new Set<string>();
311
+ nodes.forEach((node) => {
312
+ node.fx = node.x;
313
+ node.fy = node.y;
314
+ newPinned.add(node.id);
315
+ });
316
+ setPinnedNodes(newPinned);
317
+ restart();
318
+ },
319
+
320
+ unpinAll: () => {
321
+ nodes.forEach((node) => {
322
+ node.fx = null;
323
+ node.fy = null;
324
+ });
325
+ setPinnedNodes(new Set());
326
+ restart();
327
+ },
328
+
329
+ resetLayout: () => {
330
+ nodes.forEach((node) => {
331
+ node.fx = null;
332
+ node.fy = null;
333
+ });
334
+ setPinnedNodes(new Set());
335
+ restart();
336
+ },
337
+
338
+ fitView: () => {
339
+ if (!svgRef.current || !nodes.length) return;
340
+
341
+ // Calculate bounds
342
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
343
+ nodes.forEach((node) => {
344
+ if (node.x !== undefined && node.y !== undefined) {
345
+ const size = node.size || 10;
346
+ minX = Math.min(minX, node.x - size);
347
+ maxX = Math.max(maxX, node.x + size);
348
+ minY = Math.min(minY, node.y - size);
349
+ maxY = Math.max(maxY, node.y + size);
350
+ }
351
+ });
352
+
353
+ if (!isFinite(minX)) return;
354
+
355
+ const padding = 40;
356
+ const nodeWidth = maxX - minX;
357
+ const nodeHeight = maxY - minY;
358
+ const scale = Math.min(
359
+ (width - padding * 2) / nodeWidth,
360
+ (height - padding * 2) / nodeHeight,
361
+ 10
362
+ );
363
+
364
+ const centerX = (minX + maxX) / 2;
365
+ const centerY = (minY + maxY) / 2;
366
+
367
+ const x = width / 2 - centerX * scale;
368
+ const y = height / 2 - centerY * scale;
369
+
370
+ if (gRef.current && svgRef.current) {
371
+ const svg = d3.select(svgRef.current);
372
+ const newTransform = d3.zoomIdentity.translate(x, y).scale(scale);
373
+ svg.transition().duration(300).call(d3.zoom<SVGSVGElement, unknown>().transform as any, newTransform);
374
+ setTransform(newTransform);
375
+ }
376
+ },
377
+
378
+ getPinnedNodes: () => Array.from(pinnedNodes),
379
+
380
+ setDragMode: (enabled: boolean) => {
381
+ internalDragEnabledRef.current = enabled;
382
+ },
383
+ }),
384
+ [nodes, pinnedNodes, restart, width, height]
385
+ );
386
+
387
+ // Notify parent when manual layout mode changes (uses the prop so it's not unused)
388
+ useEffect(() => {
389
+ try {
390
+ if (typeof onManualLayoutChange === 'function') onManualLayoutChange(manualLayout);
391
+ } catch (e) {
392
+ // ignore errors from callbacks
393
+ }
394
+ }, [manualLayout, onManualLayoutChange]);
395
+
162
396
  // Set up zoom behavior
163
397
  useEffect(() => {
164
398
  if (!enableZoom || !svgRef.current || !gRef.current) return;
@@ -181,43 +415,119 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
181
415
  };
182
416
  }, [enableZoom]);
183
417
 
184
- // Set up drag behavior
418
+ // Set up drag behavior with global listeners for smoother dragging
185
419
  const handleDragStart = useCallback(
186
420
  (event: React.MouseEvent, node: GraphNode) => {
187
421
  if (!enableDrag) return;
422
+ event.preventDefault();
188
423
  event.stopPropagation();
424
+ // pause forces while dragging to avoid the whole graph moving
425
+ dragActiveRef.current = true;
426
+ dragNodeRef.current = node;
189
427
  node.fx = node.x;
190
428
  node.fy = node.y;
191
- restart();
429
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
430
+ try { stop(); } catch (e) {}
192
431
  },
193
432
  [enableDrag, restart]
194
433
  );
195
434
 
196
- const handleDrag = useCallback(
197
- (event: React.MouseEvent, node: GraphNode) => {
198
- if (!enableDrag) return;
435
+ useEffect(() => {
436
+ if (!enableDrag) return;
437
+
438
+ const handleWindowMove = (event: MouseEvent) => {
439
+ if (!dragActiveRef.current || !dragNodeRef.current) return;
199
440
  const svg = svgRef.current;
200
441
  if (!svg) return;
201
-
202
442
  const rect = svg.getBoundingClientRect();
203
443
  const x = (event.clientX - rect.left - transform.x) / transform.k;
204
444
  const y = (event.clientY - rect.top - transform.y) / transform.k;
445
+ dragNodeRef.current.fx = x;
446
+ dragNodeRef.current.fy = y;
447
+ };
205
448
 
206
- node.fx = x;
207
- node.fy = y;
208
- },
209
- [enableDrag, transform]
210
- );
449
+ const handleWindowUp = () => {
450
+ if (!dragActiveRef.current) return;
451
+ // Keep fx/fy set to pin the node where it was dropped.
452
+ try { setForcesEnabled(true); restart(); } catch (e) {}
453
+ dragNodeRef.current = null;
454
+ dragActiveRef.current = false;
455
+ };
211
456
 
212
- const handleDragEnd = useCallback(
213
- (event: React.MouseEvent, node: GraphNode) => {
214
- if (!enableDrag) return;
215
- event.stopPropagation();
216
- node.fx = null;
217
- node.fy = null;
218
- },
219
- [enableDrag]
220
- );
457
+ const handleWindowLeave = (event: MouseEvent) => {
458
+ if (event.relatedTarget === null) handleWindowUp();
459
+ };
460
+
461
+ window.addEventListener('mousemove', handleWindowMove);
462
+ window.addEventListener('mouseup', handleWindowUp);
463
+ window.addEventListener('mouseout', handleWindowLeave);
464
+ window.addEventListener('blur', handleWindowUp);
465
+
466
+ return () => {
467
+ window.removeEventListener('mousemove', handleWindowMove);
468
+ window.removeEventListener('mouseup', handleWindowUp);
469
+ window.removeEventListener('mouseout', handleWindowLeave);
470
+ window.removeEventListener('blur', handleWindowUp);
471
+ };
472
+ }, [enableDrag, transform]);
473
+
474
+ // Attach d3.drag behavior to node groups rendered by React. This helps make
475
+ // dragging more robust across transforms and pointer behaviors.
476
+ useEffect(() => {
477
+ if (!gRef.current || !enableDrag) return;
478
+ const g = d3.select(gRef.current);
479
+ const dragBehavior = d3
480
+ .drag<SVGGElement, unknown>()
481
+ .on('start', function (event) {
482
+ try {
483
+ const target = (event.sourceEvent && (event.sourceEvent.target as Element)) || (event.target as Element);
484
+ const grp = target.closest?.('g.node') as Element | null;
485
+ const id = grp?.getAttribute('data-id');
486
+ if (!id) return;
487
+ const node = nodes.find((n) => n.id === id) as GraphNode | undefined;
488
+ if (!node) return;
489
+ if (!internalDragEnabledRef.current) return;
490
+ if (!event.active) restart();
491
+ dragActiveRef.current = true;
492
+ dragNodeRef.current = node;
493
+ node.fx = node.x;
494
+ node.fy = node.y;
495
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
496
+ } catch (e) {
497
+ // ignore
498
+ }
499
+ })
500
+ .on('drag', function (event) {
501
+ if (!dragActiveRef.current || !dragNodeRef.current) return;
502
+ const svg = svgRef.current;
503
+ if (!svg) return;
504
+ const rect = svg.getBoundingClientRect();
505
+ const x = (event.sourceEvent.clientX - rect.left - transform.x) / transform.k;
506
+ const y = (event.sourceEvent.clientY - rect.top - transform.y) / transform.k;
507
+ dragNodeRef.current.fx = x;
508
+ dragNodeRef.current.fy = y;
509
+ })
510
+ .on('end', function () {
511
+ // re-enable forces when drag ends
512
+ try { setForcesEnabled(true); restart(); } catch (e) {}
513
+ dragNodeRef.current = null;
514
+ dragActiveRef.current = false;
515
+ });
516
+
517
+ try {
518
+ g.selectAll('g.node').call(dragBehavior as any);
519
+ } catch (e) {
520
+ // ignore attach errors
521
+ }
522
+
523
+ return () => {
524
+ try {
525
+ g.selectAll('g.node').on('.drag', null as any);
526
+ } catch (e) {
527
+ /* ignore */
528
+ }
529
+ };
530
+ }, [gRef, enableDrag, nodes, transform, restart]);
221
531
 
222
532
  const handleNodeClick = useCallback(
223
533
  (node: GraphNode) => {
@@ -226,6 +536,37 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
226
536
  [onNodeClick]
227
537
  );
228
538
 
539
+ const handleNodeDoubleClick = useCallback(
540
+ (event: React.MouseEvent, node: GraphNode) => {
541
+ event.stopPropagation();
542
+ if (!enableDrag) return;
543
+ if (node.fx === null || node.fx === undefined) {
544
+ node.fx = node.x;
545
+ node.fy = node.y;
546
+ setPinnedNodes((prev) => new Set([...prev, node.id]));
547
+ } else {
548
+ node.fx = null;
549
+ node.fy = null;
550
+ setPinnedNodes((prev) => {
551
+ const next = new Set(prev);
552
+ next.delete(node.id);
553
+ return next;
554
+ });
555
+ }
556
+ restart();
557
+ },
558
+ [enableDrag, restart]
559
+ );
560
+
561
+ const handleCanvasDoubleClick = useCallback(() => {
562
+ nodes.forEach((node) => {
563
+ node.fx = null;
564
+ node.fy = null;
565
+ });
566
+ setPinnedNodes(new Set());
567
+ restart();
568
+ }, [nodes, restart]);
569
+
229
570
  const handleNodeMouseEnter = useCallback(
230
571
  (node: GraphNode) => {
231
572
  onNodeHover?.(node);
@@ -250,6 +591,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
250
591
  width={width}
251
592
  height={height}
252
593
  className={cn('bg-white dark:bg-gray-900', className)}
594
+ onDoubleClick={handleCanvasDoubleClick}
253
595
  >
254
596
  <defs>
255
597
  {/* Arrow marker for directed graphs */}
@@ -267,11 +609,12 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
267
609
  </defs>
268
610
 
269
611
  <g ref={gRef}>
612
+
270
613
  {/* Render links */}
271
614
  {links.map((link, i) => {
272
615
  const source = link.source as GraphNode;
273
616
  const target = link.target as GraphNode;
274
- if (!source.x || !source.y || !target.x || !target.y) return null;
617
+ if (source.x == null || source.y == null || target.x == null || target.y == null) return null;
275
618
 
276
619
  return (
277
620
  <g key={`link-${i}`}>
@@ -305,7 +648,7 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
305
648
 
306
649
  {/* Render nodes */}
307
650
  {nodes.map((node) => {
308
- if (!node.x || !node.y) return null;
651
+ if (node.x == null || node.y == null) return null;
309
652
 
310
653
  const isSelected = selectedNodeId === node.id;
311
654
  const isHovered = hoveredNodeId === node.id;
@@ -314,24 +657,34 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
314
657
 
315
658
  return (
316
659
  <g
317
- key={node.id}
318
- transform={`translate(${node.x},${node.y})`}
319
- className="cursor-pointer"
320
- onClick={() => handleNodeClick(node)}
321
- onMouseEnter={() => handleNodeMouseEnter(node)}
322
- onMouseLeave={handleNodeMouseLeave}
323
- onMouseDown={(e) => handleDragStart(e, node)}
324
- onMouseMove={(e) => handleDrag(e, node)}
325
- onMouseUp={(e) => handleDragEnd(e, node)}
326
- >
660
+ key={node.id}
661
+ transform={`translate(${node.x},${node.y})`}
662
+ className="cursor-pointer node"
663
+ data-id={node.id}
664
+ onClick={() => handleNodeClick(node)}
665
+ onDoubleClick={(event) => handleNodeDoubleClick(event, node)}
666
+ onMouseEnter={() => handleNodeMouseEnter(node)}
667
+ onMouseLeave={handleNodeMouseLeave}
668
+ onMouseDown={(e) => handleDragStart(e, node)}
669
+ >
327
670
  <circle
328
671
  r={nodeSize}
329
672
  fill={nodeColor}
330
673
  stroke={isSelected ? '#000' : isHovered ? '#666' : 'none'}
331
- strokeWidth={isSelected ? 3 : 2}
674
+ strokeWidth={pinnedNodes.has(node.id) ? 3 : isSelected ? 2.5 : isHovered ? 2 : 1.5}
332
675
  opacity={isHovered || isSelected ? 1 : 0.9}
333
676
  className="transition-all"
334
677
  />
678
+ {pinnedNodes.has(node.id) && (
679
+ <circle
680
+ r={nodeSize + 4}
681
+ fill="none"
682
+ stroke="#ff6b6b"
683
+ strokeWidth={1}
684
+ opacity={0.5}
685
+ className="pointer-events-none"
686
+ />
687
+ )}
335
688
  {showNodeLabels && node.label && (
336
689
  <text
337
690
  y={nodeSize + 15}
@@ -348,9 +701,39 @@ export const ForceDirectedGraph: React.FC<ForceDirectedGraphProps> = ({
348
701
  </g>
349
702
  );
350
703
  })}
704
+ {/* Package boundary circles (from parent pack layout) - drawn on top for visibility */}
705
+ {packageBounds && Object.keys(packageBounds).length > 0 && (
706
+ <g className="package-boundaries" pointerEvents="none">
707
+ {Object.entries(packageBounds).map(([pid, b]) => (
708
+ <g key={pid}>
709
+ <circle
710
+ cx={b.x}
711
+ cy={b.y}
712
+ r={b.r}
713
+ fill="rgba(148,163,184,0.06)"
714
+ stroke="#475569"
715
+ strokeWidth={2}
716
+ strokeDasharray="6 6"
717
+ opacity={0.9}
718
+ />
719
+ <text
720
+ x={b.x}
721
+ y={Math.max(12, b.y - b.r + 14)}
722
+ fill="#475569"
723
+ fontSize={11}
724
+ textAnchor="middle"
725
+ pointerEvents="none"
726
+ >
727
+ {pid.replace(/^pkg:/, '')}
728
+ </text>
729
+ </g>
730
+ ))}
731
+ </g>
732
+ )}
351
733
  </g>
352
734
  </svg>
353
735
  );
354
- };
736
+ }
737
+ );
355
738
 
356
739
  ForceDirectedGraph.displayName = 'ForceDirectedGraph';