beads-map 0.2.4 → 0.3.0

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.
Files changed (44) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +3 -3
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/next-minimal-server.js.nft.json +1 -1
  6. package/.next/next-server.js.nft.json +1 -1
  7. package/.next/prerender-manifest.json +1 -1
  8. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_not-found.html +1 -1
  10. package/.next/server/app/_not-found.rsc +1 -1
  11. package/.next/server/app/api/beads/route.js +2 -2
  12. package/.next/server/app/api/beads/stream/route.js +3 -3
  13. package/.next/server/app/api/beads.body +1 -1
  14. package/.next/server/app/api/config/route.js +2 -2
  15. package/.next/server/app/index.html +1 -1
  16. package/.next/server/app/index.rsc +2 -2
  17. package/.next/server/app/page.js +3 -3
  18. package/.next/server/app/page_client-reference-manifest.js +1 -1
  19. package/.next/server/app-paths-manifest.json +5 -5
  20. package/.next/server/functions-config-manifest.json +1 -1
  21. package/.next/server/pages/404.html +1 -1
  22. package/.next/server/pages/500.html +1 -1
  23. package/.next/server/server-reference-manifest.json +1 -1
  24. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  25. package/.next/static/chunks/app/page-68492e6aaf15a6dd.js +1 -0
  26. package/.next/static/css/c854bc2280bc4b27.css +3 -0
  27. package/README.md +12 -4
  28. package/app/api/config/route.ts +2 -0
  29. package/app/globals.css +12 -0
  30. package/app/page.tsx +111 -6
  31. package/components/BeadTooltip.tsx +222 -0
  32. package/components/BeadsGraph.tsx +264 -56
  33. package/components/ContextMenu.tsx +49 -3
  34. package/lib/parse-beads.ts +2 -0
  35. package/lib/types.ts +2 -0
  36. package/package.json +1 -1
  37. package/public/image.png +0 -0
  38. package/.next/server/app/api/config.body +0 -1
  39. package/.next/server/app/api/config.meta +0 -1
  40. package/.next/static/chunks/666-fb778298a77f3754.js +0 -1
  41. package/.next/static/chunks/app/page-13ee27a84e4a0c70.js +0 -1
  42. package/.next/static/css/dbf588b653aa4019.css +0 -3
  43. /package/.next/static/{5zW6ptqKxGc0tcnRau9j2 → ac0cLw5kGBDWoceTBnu21}/_buildManifest.js +0 -0
  44. /package/.next/static/{5zW6ptqKxGc0tcnRau9j2 → ac0cLw5kGBDWoceTBnu21}/_ssgManifest.js +0 -0
@@ -9,7 +9,7 @@ import React, {
9
9
  useRef,
10
10
  useState,
11
11
  } from "react";
12
- import { forceCollide } from "d3-force";
12
+ import { forceCollide, forceRadial, forceX, forceY } from "d3-force";
13
13
  import type { GraphNode, GraphLink } from "@/lib/types";
14
14
  import { STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS } from "@/lib/types";
15
15
 
@@ -18,9 +18,7 @@ import { STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS } from "@/lib/types";
18
18
  // that does NOT forward refs, breaking graphRef.current.centerAt/zoom/etc.
19
19
  let _ForceGraph2DModule: React.ComponentType<any> | null = null;
20
20
 
21
- type LayoutMode = "force" | "dag";
22
- type ViewMode = "full" | "epics";
23
-
21
+ type LayoutMode = "force" | "dag" | "radial" | "cluster" | "spread";
24
22
  export interface BeadsGraphHandle {
25
23
  focusNode: (node: GraphNode) => void;
26
24
  }
@@ -31,7 +29,7 @@ interface BeadsGraphProps {
31
29
  selectedNode: GraphNode | null;
32
30
  hoveredNode: GraphNode | null;
33
31
  onNodeClick: (node: GraphNode) => void;
34
- onNodeHover: (node: GraphNode | null) => void;
32
+ onNodeHover: (node: GraphNode | null, x: number, y: number) => void;
35
33
  onBackgroundClick: () => void;
36
34
  onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void;
37
35
  commentedNodeIds?: Map<string, number>;
@@ -41,6 +39,12 @@ interface BeadsGraphProps {
41
39
  stats?: { total: number; edges: number; prefixes: string[] };
42
40
  /** When a right sidebar (NodeDetail, Comments, Activity) is open, shift bottom-right legend inward */
43
41
  sidebarOpen?: boolean;
42
+ /** Set of epic IDs that are currently collapsed */
43
+ collapsedEpicIds?: Set<string>;
44
+ /** Collapse all epics at once */
45
+ onCollapseAll?: () => void;
46
+ /** Expand all epics at once */
47
+ onExpandAll?: () => void;
44
48
  }
45
49
 
46
50
  // Node size calculation
@@ -208,6 +212,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
208
212
  timelineActive,
209
213
  stats,
210
214
  sidebarOpen,
215
+ collapsedEpicIds,
216
+ onCollapseAll,
217
+ onExpandAll,
211
218
  }, ref) {
212
219
  const graphRef = useRef<any>(null);
213
220
  const containerRef = useRef<HTMLDivElement>(null);
@@ -247,8 +254,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
247
254
  // Layout mode: "force" (physics-based) or "dag" (topological top-down)
248
255
  const [layoutMode, setLayoutMode] = useState<LayoutMode>("dag");
249
256
 
250
- // View mode: "full" (all nodes) or "epics" (collapse children into parent epics)
251
- const [viewMode, setViewMode] = useState<ViewMode>("full");
252
257
 
253
258
 
254
259
 
@@ -272,6 +277,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
272
277
  // Track which avatar is currently hovered to avoid redundant callbacks
273
278
  const hoveredAvatarNodeRef = useRef<string | null>(null);
274
279
 
280
+ // Track last mouse position for passing coordinates with onNodeHover
281
+ const lastMouseRef = useRef({ x: 0, y: 0 });
282
+
275
283
  // Ref for current viewNodes (used by mousemove handler to respect epics view)
276
284
  const viewNodesRef = useRef<GraphNode[]>(nodes);
277
285
 
@@ -286,14 +294,14 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
286
294
  };
287
295
  const clustersRef = useRef<ClusterInfo[]>([]);
288
296
 
289
- // Compute collapsed view when in "epics" mode.
290
- // Builds a childparent map from parent-child dependencies and hierarchical IDs,
297
+ // Compute collapsed view when any epics are collapsed via collapsedEpicIds.
298
+ // Builds a child->parent map from parent-child dependencies and hierarchical IDs,
291
299
  // then removes child nodes and remaps their links to the parent epic.
292
300
  // Must be declared BEFORE effects that reference viewNodes/viewLinks.
293
301
  const { viewNodes, viewLinks } = useMemo(() => {
294
- if (viewMode === "full") return { viewNodes: nodes, viewLinks: links };
302
+ if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: nodes, viewLinks: links };
295
303
 
296
- // Build childparent map from parent-child links
304
+ // Build child->parent map from parent-child links
297
305
  const childToParent = new Map<string, string>();
298
306
  for (const link of links) {
299
307
  const src = typeof link.source === "object" ? (link.source as any).id : link.source;
@@ -303,7 +311,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
303
311
  childToParent.set(tgt, src);
304
312
  }
305
313
  }
306
- // Fallback: infer from hierarchical IDs (e.g., "beads-map-3r3.1" parent "beads-map-3r3")
314
+ // Fallback: infer from hierarchical IDs (e.g., "beads-map-3r3.1" -> parent "beads-map-3r3")
307
315
  const nodeIds = new Set(nodes.map((n) => n.id));
308
316
  for (const node of nodes) {
309
317
  if (!childToParent.has(node.id) && node.id.includes(".")) {
@@ -314,13 +322,27 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
314
322
  }
315
323
  }
316
324
 
317
- const childIds = new Set(childToParent.keys());
325
+ // Collapse children whose parent is in collapsedEpicIds
326
+ const childIds = new Set<string>();
327
+ for (const [childId, parentId] of childToParent) {
328
+ if (collapsedEpicIds.has(parentId)) {
329
+ childIds.add(childId);
330
+ }
331
+ }
332
+
333
+ if (childIds.size === 0) return { viewNodes: nodes, viewLinks: links };
334
+
335
+ // Also build a filtered childToParent for only the collapsed children (for link remapping)
336
+ const collapsedChildToParent = new Map<string, string>();
337
+ for (const childId of childIds) {
338
+ collapsedChildToParent.set(childId, childToParent.get(childId)!);
339
+ }
318
340
 
319
341
  // Accumulate collapsed children count and extra connections onto parent nodes
320
342
  const collapsedCounts = new Map<string, number>();
321
343
  const extraBlockerCount = new Map<string, number>();
322
344
  const extraDependentCount = new Map<string, number>();
323
- for (const [childId, parentId] of childToParent) {
345
+ for (const [childId, parentId] of collapsedChildToParent) {
324
346
  collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1);
325
347
  const child = nodes.find((n) => n.id === childId);
326
348
  if (child) {
@@ -329,7 +351,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
329
351
  }
330
352
  }
331
353
 
332
- // Filter nodes: remove children, augment parents
354
+ // Filter nodes: remove collapsed children, augment their parents
333
355
  const filteredNodes: GraphNode[] = nodes
334
356
  .filter((n) => !childIds.has(n.id))
335
357
  .map((n) => ({
@@ -339,15 +361,17 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
339
361
  collapsedCount: collapsedCounts.get(n.id) || 0,
340
362
  }));
341
363
 
342
- // Remap links: replace child IDs with parent IDs, drop parent-child and self-links
364
+ // Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links
343
365
  const remappedLinks: GraphLink[] = [];
344
366
  const linkSeen = new Set<string>();
345
367
  for (const link of links) {
346
- if (link.type === "parent-child") continue; // internal to collapsed epic
347
368
  let src = typeof link.source === "object" ? (link.source as any).id : link.source;
348
369
  let tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
349
- src = childToParent.get(src) || src;
350
- tgt = childToParent.get(tgt) || tgt;
370
+ // Drop parent-child links where the child is collapsed
371
+ if (link.type === "parent-child" && childIds.has(tgt)) continue;
372
+ // Remap collapsed child endpoints to their parent
373
+ src = collapsedChildToParent.get(src) || src;
374
+ tgt = collapsedChildToParent.get(tgt) || tgt;
351
375
  if (src === tgt) continue; // self-link after collapse
352
376
  const key = `${src}->${tgt}:${link.type}`;
353
377
  if (linkSeen.has(key)) continue;
@@ -356,7 +380,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
356
380
  }
357
381
 
358
382
  return { viewNodes: filteredNodes, viewLinks: remappedLinks };
359
- }, [nodes, links, viewMode]);
383
+ }, [nodes, links, collapsedEpicIds]);
360
384
 
361
385
  // Keep viewNodesRef in sync for mousemove avatar hit-testing
362
386
  viewNodesRef.current = viewNodes;
@@ -463,6 +487,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
463
487
  if (!container) return;
464
488
 
465
489
  const handleMouseMove = (e: MouseEvent) => {
490
+ // Track last mouse position for onNodeHover coordinates
491
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
492
+
466
493
  const fg = graphRef.current;
467
494
  const cb = onAvatarHoverRef.current;
468
495
  if (!fg || !cb) return;
@@ -575,16 +602,139 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
575
602
  const fg = graphRef.current;
576
603
  if (!fg || viewNodes.length === 0) return;
577
604
 
605
+ // Helper: clear custom forces that only specific layouts use.
606
+ // Must be called at the start of every branch to prevent stale forces.
607
+ const clearCustomForces = () => {
608
+ fg.d3Force("radial", null);
609
+ fg.d3Force("x", null);
610
+ fg.d3Force("y", null);
611
+ };
612
+
613
+ // Helper: clear fixed positions left over from DAG mode.
614
+ const clearFixedPositions = () => {
615
+ viewNodes.forEach((node: any) => {
616
+ delete node.fx;
617
+ delete node.fy;
618
+ });
619
+ };
620
+
578
621
  if (layoutMode === "dag") {
579
622
  // DAG mode: weaker charge since vertical spacing is handled by layers.
623
+ clearCustomForces();
580
624
  fg.d3Force("charge")?.strength(-80).distanceMax(300);
581
625
  fg.d3Force("link")?.distance(50).strength(0.3);
582
626
  fg.d3Force("center")?.strength(0.01);
583
-
584
627
  // Remove collision in DAG mode (layers handle vertical separation)
585
628
  fg.d3Force("collision", null);
629
+
630
+ } else if (layoutMode === "radial") {
631
+ // Radial layout: concentric rings by dependency depth.
632
+ // Compute BFS depth from root nodes (no incoming blocks edges).
633
+ clearCustomForces();
634
+ clearFixedPositions();
635
+
636
+ const incoming = new Map<string, string[]>();
637
+ for (const link of viewLinks) {
638
+ if (link.type === "parent-child") continue;
639
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
640
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
641
+ if (!incoming.has(tgt)) incoming.set(tgt, []);
642
+ incoming.get(tgt)!.push(src);
643
+ }
644
+ const depthMap = new Map<string, number>();
645
+ const queue: string[] = [];
646
+ viewNodes.forEach((n: any) => {
647
+ if (!incoming.has(n.id)) { depthMap.set(n.id, 0); queue.push(n.id); }
648
+ });
649
+ let qi = 0;
650
+ while (qi < queue.length) {
651
+ const id = queue[qi++];
652
+ const d = depthMap.get(id)!;
653
+ for (const link of viewLinks) {
654
+ if (link.type === "parent-child") continue;
655
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
656
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
657
+ if (src === id && !depthMap.has(tgt)) {
658
+ depthMap.set(tgt, d + 1);
659
+ queue.push(tgt);
660
+ }
661
+ }
662
+ }
663
+ // Store depth transiently on each node for the radial force accessor
664
+ viewNodes.forEach((n: any) => { n._depth = depthMap.get(n.id) ?? 0; });
665
+
666
+ // Scale ring spacing by node count so rings don't overlap
667
+ const maxDepth = Math.max(1, ...Array.from(depthMap.values()));
668
+ const ringSpacing = Math.max(200, viewNodes.length * 4);
669
+
670
+ fg.d3Force("charge")?.strength(-300).distanceMax(800);
671
+ fg.d3Force("link")?.distance(150).strength(0.15);
672
+ fg.d3Force("center")?.strength(0); // no center pull — radial handles centering
673
+ fg.d3Force("radial",
674
+ forceRadial(
675
+ (node: any) => ((node as any)._depth || 0) * ringSpacing,
676
+ 0, 0
677
+ ).strength(0.8)
678
+ );
679
+ fg.d3Force("x", null); // let radial + charge handle positioning
680
+ fg.d3Force("y", null);
681
+ fg.d3Force("collision",
682
+ forceCollide()
683
+ .radius((node: any) => getNodeSize(node as GraphNode) + 10)
684
+ .strength(0.9)
685
+ );
686
+
687
+ } else if (layoutMode === "cluster") {
688
+ // Cluster layout: group nodes by project prefix.
689
+ clearCustomForces();
690
+ clearFixedPositions();
691
+
692
+ const prefixes = [...new Set(viewNodes.map((n: any) => (n as GraphNode).prefix))];
693
+ // Scale cluster separation by total node count — more nodes need more space
694
+ const radius = Math.max(400, viewNodes.length * 5, prefixes.length * 150);
695
+ const prefixCenters = new Map<string, { x: number; y: number }>();
696
+ prefixes.forEach((prefix, i) => {
697
+ const angle = (2 * Math.PI * i) / prefixes.length - Math.PI / 2;
698
+ prefixCenters.set(prefix, {
699
+ x: Math.cos(angle) * radius,
700
+ y: Math.sin(angle) * radius,
701
+ });
702
+ });
703
+
704
+ fg.d3Force("charge")?.strength(-200).distanceMax(600);
705
+ fg.d3Force("link")?.distance(100).strength(0.15);
706
+ fg.d3Force("center")?.strength(0); // no center pull — x/y handle positioning
707
+ fg.d3Force("x",
708
+ forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.5)
709
+ );
710
+ fg.d3Force("y",
711
+ forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.5)
712
+ );
713
+ fg.d3Force("collision",
714
+ forceCollide()
715
+ .radius((node: any) => getNodeSize(node as GraphNode) + 10)
716
+ .strength(0.9)
717
+ );
718
+
719
+ } else if (layoutMode === "spread") {
720
+ // Spread layout: like force but maximally spaced for readability.
721
+ clearCustomForces();
722
+ clearFixedPositions();
723
+
724
+ fg.d3Force("charge")?.strength(-300).distanceMax(500);
725
+ fg.d3Force("link")?.distance(180).strength(0.4);
726
+ fg.d3Force("center")?.strength(0.02);
727
+ fg.d3Force("collision",
728
+ forceCollide()
729
+ .radius((node: any) => getNodeSize(node as GraphNode) + 8)
730
+ .strength(0.8)
731
+ );
732
+
586
733
  } else {
587
- // Force mode: full physics
734
+ // Force mode: full physics (default)
735
+ clearCustomForces();
736
+ clearFixedPositions();
737
+
588
738
  fg.d3Force("charge")?.strength(-180).distanceMax(400);
589
739
  fg.d3Force("link")
590
740
  ?.distance((link: any) => {
@@ -599,23 +749,11 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
599
749
  })
600
750
  .strength(0.6);
601
751
  fg.d3Force("center")?.strength(0.03);
602
-
603
- // Add collision force (prevent node overlap).
604
- // Using static import so collision is active from tick 1 (including warmup).
605
- fg.d3Force(
606
- "collision",
752
+ fg.d3Force("collision",
607
753
  forceCollide()
608
754
  .radius((node: any) => getNodeSize(node as GraphNode) + 6)
609
755
  .strength(0.7)
610
756
  );
611
-
612
- // Clear any fixed positions left over from DAG mode.
613
- // The force simulation mutates node objects in-place, so the nodes
614
- // prop array already has fx/fy set by DAG mode — delete them directly.
615
- viewNodes.forEach((node: any) => {
616
- delete node.fx;
617
- delete node.fy;
618
- });
619
757
  }
620
758
 
621
759
  // Re-heat simulation so new forces take effect immediately
@@ -630,7 +768,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
630
768
  initialLayoutApplied.current = true;
631
769
 
632
770
  return () => clearTimeout(timer);
633
- }, [layoutMode, viewNodes.length]);
771
+ }, [layoutMode, viewNodes, viewLinks]);
634
772
 
635
773
  // Bootstrap trick: start in DAG to spread nodes into good positions,
636
774
  // then auto-switch to Force mode. This replicates the exact code path
@@ -1643,14 +1781,11 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1643
1781
  <span className="hidden sm:inline">DAG</span>
1644
1782
  </span>
1645
1783
  </button>
1646
- </div>
1647
-
1648
- {/* View mode toggle: Full / Epics */}
1649
- <div className="flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden">
1784
+ <div className="w-px bg-zinc-200" />
1650
1785
  <button
1651
- onClick={() => setViewMode("full")}
1786
+ onClick={() => setLayoutMode("radial")}
1652
1787
  className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1653
- viewMode === "full"
1788
+ layoutMode === "radial"
1654
1789
  ? "bg-emerald-500 text-white"
1655
1790
  : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1656
1791
  }`}
@@ -1663,21 +1798,47 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1663
1798
  stroke="currentColor"
1664
1799
  strokeWidth="1.5"
1665
1800
  >
1666
- {/* Grid/all icon: 4 dots in a grid */}
1667
- <circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
1668
- <circle cx="12" cy="4" r="1.5" fill="currentColor" stroke="none" />
1669
- <circle cx="4" cy="12" r="1.5" fill="currentColor" stroke="none" />
1670
- <circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
1671
- <circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" opacity="0.5" />
1801
+ {/* Radial icon: concentric rings with center dot */}
1802
+ <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
1803
+ <circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
1804
+ <circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
1805
+ </svg>
1806
+ <span className="hidden sm:inline">Radial</span>
1807
+ </span>
1808
+ </button>
1809
+ <div className="w-px bg-zinc-200" />
1810
+ <button
1811
+ onClick={() => setLayoutMode("cluster")}
1812
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1813
+ layoutMode === "cluster"
1814
+ ? "bg-emerald-500 text-white"
1815
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1816
+ }`}
1817
+ >
1818
+ <span className="flex items-center gap-1.5">
1819
+ <svg
1820
+ className="w-3.5 h-3.5"
1821
+ viewBox="0 0 16 16"
1822
+ fill="none"
1823
+ stroke="currentColor"
1824
+ strokeWidth="1.5"
1825
+ >
1826
+ {/* Cluster icon: two groups of dots */}
1827
+ <circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
1828
+ <circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
1829
+ <circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
1830
+ <circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
1831
+ <circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
1832
+ <circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
1672
1833
  </svg>
1673
- <span className="hidden sm:inline">Full</span>
1834
+ <span className="hidden sm:inline">Cluster</span>
1674
1835
  </span>
1675
1836
  </button>
1676
1837
  <div className="w-px bg-zinc-200" />
1677
1838
  <button
1678
- onClick={() => setViewMode("epics")}
1839
+ onClick={() => setLayoutMode("spread")}
1679
1840
  className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1680
- viewMode === "epics"
1841
+ layoutMode === "spread"
1681
1842
  ? "bg-emerald-500 text-white"
1682
1843
  : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1683
1844
  }`}
@@ -1690,14 +1851,61 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1690
1851
  stroke="currentColor"
1691
1852
  strokeWidth="1.5"
1692
1853
  >
1693
- {/* Collapsed/epic icon: large dot with small dots absorbed */}
1694
- <circle cx="8" cy="8" r="4" fill="currentColor" stroke="none" />
1695
- <circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" opacity="0.3" />
1854
+ {/* Spread icon: dots spread far apart */}
1855
+ <circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
1856
+ <circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
1857
+ <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
1858
+ <circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
1859
+ <circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
1696
1860
  </svg>
1697
- <span className="hidden sm:inline">Epics</span>
1861
+ <span className="hidden sm:inline">Spread</span>
1698
1862
  </span>
1699
1863
  </button>
1700
1864
  </div>
1865
+
1866
+ {/* Collapse / Expand all toggle */}
1867
+ {(onCollapseAll || onExpandAll) && (
1868
+ <button
1869
+ onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
1870
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50 transition-colors"
1871
+ >
1872
+ {collapsedEpicIds && collapsedEpicIds.size > 0 ? (
1873
+ <>
1874
+ <svg
1875
+ className="w-3.5 h-3.5"
1876
+ viewBox="0 0 24 24"
1877
+ fill="none"
1878
+ strokeWidth={1.5}
1879
+ stroke="currentColor"
1880
+ >
1881
+ <path
1882
+ strokeLinecap="round"
1883
+ strokeLinejoin="round"
1884
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
1885
+ />
1886
+ </svg>
1887
+ <span className="hidden sm:inline">Expand all</span>
1888
+ </>
1889
+ ) : (
1890
+ <>
1891
+ <svg
1892
+ className="w-3.5 h-3.5"
1893
+ viewBox="0 0 24 24"
1894
+ fill="none"
1895
+ strokeWidth={1.5}
1896
+ stroke="currentColor"
1897
+ >
1898
+ <path
1899
+ strokeLinecap="round"
1900
+ strokeLinejoin="round"
1901
+ d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
1902
+ />
1903
+ </svg>
1904
+ <span className="hidden sm:inline">Collapse all</span>
1905
+ </>
1906
+ )}
1907
+ </button>
1908
+ )}
1701
1909
  </div>
1702
1910
 
1703
1911
  {/* Bottom-right info panel: stats + status colors + legend (hidden when timeline active) */}
@@ -1867,7 +2075,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1867
2075
  // Interactions
1868
2076
  onNodeClick={(node: any) => onNodeClick(node as GraphNode)}
1869
2077
  onNodeHover={(node: any) =>
1870
- onNodeHover(node ? (node as GraphNode) : null)
2078
+ onNodeHover(node ? (node as GraphNode) : null, lastMouseRef.current.x, lastMouseRef.current.y)
1871
2079
  }
1872
2080
  onNodeRightClick={(node: any, event: MouseEvent) => {
1873
2081
  event.preventDefault();
@@ -11,6 +11,8 @@ interface ContextMenuProps {
11
11
  onAddComment: () => void;
12
12
  onClaimTask?: () => void;
13
13
  onUnclaimTask?: () => void;
14
+ onCollapseEpic?: () => void;
15
+ onUncollapseEpic?: () => void;
14
16
  onClose: () => void;
15
17
  }
16
18
 
@@ -22,6 +24,8 @@ export function ContextMenu({
22
24
  onAddComment,
23
25
  onClaimTask,
24
26
  onUnclaimTask,
27
+ onCollapseEpic,
28
+ onUncollapseEpic,
25
29
  onClose,
26
30
  }: ContextMenuProps) {
27
31
  const menuRef = useRef<HTMLDivElement>(null);
@@ -115,7 +119,7 @@ export function ContextMenu({
115
119
  )}
116
120
  <button
117
121
  onClick={onAddComment}
118
- className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onClaimTask || onUnclaimTask ? " border-b border-zinc-100" : ""}`}
122
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onClaimTask || onUnclaimTask || onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
119
123
  >
120
124
  <svg
121
125
  className="w-3.5 h-3.5 text-zinc-400"
@@ -135,7 +139,7 @@ export function ContextMenu({
135
139
  {onClaimTask && (
136
140
  <button
137
141
  onClick={onClaimTask}
138
- className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
142
+ className={`w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
139
143
  >
140
144
  <svg
141
145
  className="w-3.5 h-3.5 text-zinc-400"
@@ -156,7 +160,7 @@ export function ContextMenu({
156
160
  {onUnclaimTask && (
157
161
  <button
158
162
  onClick={onUnclaimTask}
159
- className="w-full px-3 py-2.5 text-xs text-red-500 hover:bg-red-50 flex items-center gap-2 transition-colors"
163
+ className={`w-full px-3 py-2.5 text-xs text-red-500 hover:bg-red-50 flex items-center gap-2 transition-colors${onCollapseEpic || onUncollapseEpic ? " border-b border-zinc-100" : ""}`}
160
164
  >
161
165
  <svg
162
166
  className="w-3.5 h-3.5 text-red-400"
@@ -174,6 +178,48 @@ export function ContextMenu({
174
178
  Unclaim task
175
179
  </button>
176
180
  )}
181
+ {onCollapseEpic && (
182
+ <button
183
+ onClick={onCollapseEpic}
184
+ className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
185
+ >
186
+ <svg
187
+ className="w-3.5 h-3.5 text-zinc-400"
188
+ fill="none"
189
+ viewBox="0 0 24 24"
190
+ strokeWidth={1.5}
191
+ stroke="currentColor"
192
+ >
193
+ <path
194
+ strokeLinecap="round"
195
+ strokeLinejoin="round"
196
+ d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
197
+ />
198
+ </svg>
199
+ Collapse epic
200
+ </button>
201
+ )}
202
+ {onUncollapseEpic && (
203
+ <button
204
+ onClick={onUncollapseEpic}
205
+ className="w-full px-3 py-2.5 text-xs text-zinc-700 hover:bg-zinc-50 flex items-center gap-2 transition-colors"
206
+ >
207
+ <svg
208
+ className="w-3.5 h-3.5 text-zinc-400"
209
+ fill="none"
210
+ viewBox="0 0 24 24"
211
+ strokeWidth={1.5}
212
+ stroke="currentColor"
213
+ >
214
+ <path
215
+ strokeLinecap="round"
216
+ strokeLinejoin="round"
217
+ d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
218
+ />
219
+ </svg>
220
+ Uncollapse epic
221
+ </button>
222
+ )}
177
223
  </div>
178
224
  </div>
179
225
  );
@@ -145,6 +145,8 @@ export function buildGraphData(
145
145
  priority: issue.priority,
146
146
  issueType: issue.issue_type,
147
147
  owner: issue.owner,
148
+ assignee: issue.assignee,
149
+ createdBy: issue.created_by,
148
150
  createdAt: issue.created_at,
149
151
  updatedAt: issue.updated_at,
150
152
  closedAt: issue.closed_at,
package/lib/types.ts CHANGED
@@ -42,6 +42,8 @@ export interface GraphNode {
42
42
  priority: number;
43
43
  issueType: BeadIssue["issue_type"];
44
44
  owner?: string;
45
+ assignee?: string;
46
+ createdBy?: string;
45
47
  createdAt: string;
46
48
  updatedAt: string;
47
49
  closedAt?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beads-map",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Interactive dependency graph viewer for beads (bd) issues",
5
5
  "keywords": [
6
6
  "beads",
package/public/image.png CHANGED
Binary file
@@ -1 +0,0 @@
1
- {"name":"beads-map","prefix":null,"repoCount":1,"repos":["."],"repoUrls":{"beads-map":"https://github.com/GainForest/beads-map"}}
@@ -1 +0,0 @@
1
- {"status":200,"headers":{"content-type":"application/json","x-next-cache-tags":"_N_T_/layout,_N_T_/api/layout,_N_T_/api/config/layout,_N_T_/api/config/route,_N_T_/api/config"}}