beads-map 0.3.0 → 0.3.1

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 (30) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +2 -2
  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.body +1 -1
  12. package/.next/server/app/index.html +1 -1
  13. package/.next/server/app/index.rsc +2 -2
  14. package/.next/server/app/page.js +3 -3
  15. package/.next/server/app/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app-paths-manifest.json +1 -1
  17. package/.next/server/functions-config-manifest.json +1 -1
  18. package/.next/server/pages/404.html +1 -1
  19. package/.next/server/pages/500.html +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/app/page-a0493d6741516b53.js +1 -0
  22. package/.next/static/css/4fded26534cb91e3.css +3 -0
  23. package/app/page.tsx +6 -3
  24. package/components/BeadsGraph.tsx +157 -23
  25. package/lib/types.ts +66 -0
  26. package/package.json +1 -1
  27. package/.next/static/chunks/app/page-68492e6aaf15a6dd.js +0 -1
  28. package/.next/static/css/c854bc2280bc4b27.css +0 -3
  29. /package/.next/static/{ac0cLw5kGBDWoceTBnu21 → _OvcD8YYgVPHv6Tomg-pB}/_buildManifest.js +0 -0
  30. /package/.next/static/{ac0cLw5kGBDWoceTBnu21 → _OvcD8YYgVPHv6Tomg-pB}/_ssgManifest.js +0 -0
package/app/page.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState, useCallback, useMemo, useRef } from "react";
4
- import type { BeadsApiResponse, GraphNode, GraphLink } from "@/lib/types";
5
- import { getPrefixColor } from "@/lib/types";
4
+ import type { BeadsApiResponse, GraphNode, GraphLink, ColorMode } from "@/lib/types";
5
+ import { getCatppuccinPrefixColor } from "@/lib/types";
6
6
  import { diffBeadsData, linkKey } from "@/lib/diff-beads";
7
7
  import type { BeadsDiff } from "@/lib/diff-beads";
8
8
  import BeadsGraph from "@/components/BeadsGraph";
@@ -189,6 +189,7 @@ export default function Home() {
189
189
  const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
190
190
  const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
191
191
  const [collapsedEpicIds, setCollapsedEpicIds] = useState<Set<string>>(new Set());
192
+ const [colorMode, setColorMode] = useState<ColorMode>("status");
192
193
  const [projectName, setProjectName] = useState("Beads");
193
194
  const [repoCount, setRepoCount] = useState(0);
194
195
  const [repoUrls, setRepoUrls] = useState<Record<string, string>>({});
@@ -1248,6 +1249,8 @@ export default function Home() {
1248
1249
  collapsedEpicIds={collapsedEpicIds}
1249
1250
  onCollapseAll={handleCollapseAll}
1250
1251
  onExpandAll={handleExpandAll}
1252
+ colorMode={colorMode}
1253
+ onColorModeChange={setColorMode}
1251
1254
  />
1252
1255
 
1253
1256
  {/* Timeline bar — replaces legend hint when active */}
@@ -1382,7 +1385,7 @@ export default function Home() {
1382
1385
  node={nodeTooltip.node}
1383
1386
  x={nodeTooltip.x}
1384
1387
  y={nodeTooltip.y}
1385
- prefixColor={getPrefixColor(nodeTooltip.node.prefix)}
1388
+ prefixColor={getCatppuccinPrefixColor(nodeTooltip.node.prefix)}
1386
1389
  allNodes={timelineActive && timelineData ? timelineData.graphData.nodes : data.graphData.nodes}
1387
1390
  />
1388
1391
  )}
@@ -10,8 +10,11 @@ import React, {
10
10
  useState,
11
11
  } from "react";
12
12
  import { forceCollide, forceRadial, forceX, forceY } from "d3-force";
13
- import type { GraphNode, GraphLink } from "@/lib/types";
14
- import { STATUS_COLORS, STATUS_LABELS, PREFIX_COLORS } from "@/lib/types";
13
+ import type { GraphNode, GraphLink, ColorMode } from "@/lib/types";
14
+ import {
15
+ STATUS_COLORS, STATUS_LABELS,
16
+ COLOR_MODE_LABELS, getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,
17
+ } from "@/lib/types";
15
18
 
16
19
  // Lazy-load ForceGraph2D client-side (it requires window/document).
17
20
  // We avoid next/dynamic because it wraps the component in a LoadableComponent
@@ -45,6 +48,10 @@ interface BeadsGraphProps {
45
48
  onCollapseAll?: () => void;
46
49
  /** Expand all epics at once */
47
50
  onExpandAll?: () => void;
51
+ /** Current color mode for node body fill */
52
+ colorMode?: ColorMode;
53
+ /** Callback to change color mode (from legend selector) */
54
+ onColorModeChange?: (mode: ColorMode) => void;
48
55
  }
49
56
 
50
57
  // Node size calculation
@@ -62,14 +69,27 @@ function getNodeSize(node: GraphNode): number {
62
69
  return MIN_SIZE + normalized * (MAX_SIZE - MIN_SIZE);
63
70
  }
64
71
 
65
- // Get color based on status
72
+ // Module-level color mode tracker (synced from component via useEffect)
73
+ let _currentColorMode: ColorMode = "status";
74
+
75
+ // Get color based on current color mode
66
76
  function getNodeColor(node: GraphNode): string {
67
- return STATUS_COLORS[node.status] || STATUS_COLORS.open;
77
+ switch (_currentColorMode) {
78
+ case "owner":
79
+ return getPersonColor(node.createdBy);
80
+ case "assignee":
81
+ return getPersonColor(node.assignee);
82
+ case "prefix":
83
+ return getCatppuccinPrefixColor(node.prefix);
84
+ case "status":
85
+ default:
86
+ return STATUS_COLORS[node.status] || STATUS_COLORS.open;
87
+ }
68
88
  }
69
89
 
70
- // Get a slightly transparent version of the prefix color for the ring
71
- function getPrefixColor(node: GraphNode): string {
72
- return PREFIX_COLORS[node.prefix] || "#a1a1aa";
90
+ // Get prefix color for the outer ring uses Catppuccin palette for consistency
91
+ function getPrefixRingColor(node: GraphNode): string {
92
+ return getCatppuccinPrefixColor(node.prefix);
73
93
  }
74
94
 
75
95
  // Animation duration constants
@@ -215,6 +235,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
215
235
  collapsedEpicIds,
216
236
  onCollapseAll,
217
237
  onExpandAll,
238
+ colorMode = "status",
239
+ onColorModeChange,
218
240
  }, ref) {
219
241
  const graphRef = useRef<any>(null);
220
242
  const containerRef = useRef<HTMLDivElement>(null);
@@ -254,6 +276,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
254
276
  // Layout mode: "force" (physics-based) or "dag" (topological top-down)
255
277
  const [layoutMode, setLayoutMode] = useState<LayoutMode>("dag");
256
278
 
279
+ // Whether to show hierarchical cluster circles/labels when zoomed out
280
+ const [showClusters, setShowClusters] = useState(true);
281
+
257
282
 
258
283
 
259
284
 
@@ -266,6 +291,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
266
291
  const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>>(
267
292
  claimedNodeAvatars || new Map()
268
293
  );
294
+ // Color mode ref for paintNode (which has [] deps) to read current color mode
295
+ const colorModeRef = useRef<ColorMode>(colorMode);
296
+
269
297
  // Callback ref for refreshing graph when avatar images finish loading
270
298
  const avatarRefreshRef = useRef<() => void>(() => {});
271
299
  avatarRefreshRef.current = () => refreshGraph(graphRef);
@@ -433,6 +461,41 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
433
461
  clustersRef.current = clusters;
434
462
  }, [nodes, links]);
435
463
 
464
+ // Compute dynamic legend items based on color mode and visible nodes
465
+ const legendItems = useMemo(() => {
466
+ if (colorMode === "status") return []; // handled by static STATUS_COLORS rendering
467
+
468
+ const items = new Map<string, string>(); // label -> color
469
+
470
+ for (const node of viewNodes) {
471
+ switch (colorMode) {
472
+ case "owner": {
473
+ const key = node.createdBy || undefined;
474
+ items.set(key || "Unassigned", getPersonColor(key));
475
+ break;
476
+ }
477
+ case "assignee": {
478
+ const key = node.assignee || undefined;
479
+ items.set(key || "Unassigned", getPersonColor(key));
480
+ break;
481
+ }
482
+ case "prefix": {
483
+ items.set(getPrefixLabel(node.prefix), getCatppuccinPrefixColor(node.prefix));
484
+ break;
485
+ }
486
+ }
487
+ }
488
+
489
+ // Sort: "Unassigned" last, others alphabetically
490
+ return Array.from(items.entries())
491
+ .sort(([a], [b]) => {
492
+ if (a === "Unassigned") return 1;
493
+ if (b === "Unassigned") return -1;
494
+ return a.localeCompare(b);
495
+ })
496
+ .map(([label, color]) => ({ label, color }));
497
+ }, [colorMode, viewNodes]);
498
+
436
499
  // Sync props into refs and trigger canvas redraw (not React re-render).
437
500
  // Also schedules a minimap redraw so highlight state is synced there too.
438
501
  // Uses viewLinks (respects epic collapse) for connected subgraph computation.
@@ -481,6 +544,14 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
481
544
  refreshGraph(graphRef);
482
545
  }, [claimedNodeAvatars]);
483
546
 
547
+ // Sync color mode to module-level variable and ref, trigger canvas + minimap redraw
548
+ useEffect(() => {
549
+ colorModeRef.current = colorMode;
550
+ _currentColorMode = colorMode;
551
+ refreshGraph(graphRef);
552
+ minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
553
+ }, [colorMode]);
554
+
484
555
  // Avatar hover detection: mousemove on container, hit-test against avatar positions
485
556
  useEffect(() => {
486
557
  const container = containerRef.current;
@@ -827,7 +898,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
827
898
  const graphNode = node as GraphNode;
828
899
  const size = getNodeSize(graphNode);
829
900
  const color = getNodeColor(graphNode);
830
- const prefixColor = getPrefixColor(graphNode);
901
+ const prefixColor = getPrefixRingColor(graphNode);
831
902
  const isSelected = selectedNodeRef.current?.id === graphNode.id;
832
903
  const isHovered = hoveredNodeRef.current?.id === graphNode.id;
833
904
  const connected = connectedNodesRef.current;
@@ -1262,6 +1333,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1262
1333
  // Appears as nodes fade out (inverse of node fade).
1263
1334
  const paintClusterLabels = useCallback(
1264
1335
  (ctx: CanvasRenderingContext2D, globalScale: number) => {
1336
+ if (!showClusters) return;
1337
+
1265
1338
  // Only show cluster labels when zoomed out (inverse of node fade range)
1266
1339
  const LABEL_FADE_IN = 0.8; // starts appearing
1267
1340
  const LABEL_FULL = 0.4; // fully visible
@@ -1321,8 +1394,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1321
1394
  }
1322
1395
  const radius = maxDist + 30; // padding around outermost node
1323
1396
 
1324
- // Use project prefix color for the cluster circle
1325
- const clusterColor = PREFIX_COLORS[cluster.prefix] || "hsl(0, 0%, 65%)";
1397
+ // Use Catppuccin prefix color for the cluster circle (clusters always represent projects)
1398
+ const clusterColor = getCatppuccinPrefixColor(cluster.prefix);
1326
1399
 
1327
1400
  // Draw subtle cluster background circle
1328
1401
  ctx.beginPath();
@@ -1374,7 +1447,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1374
1447
 
1375
1448
  ctx.restore();
1376
1449
  },
1377
- [viewNodes, nodes] // reads clustersRef (ref), but needs viewNodes for positions
1450
+ [viewNodes, nodes, showClusters] // reads clustersRef (ref), but needs viewNodes for positions
1378
1451
  );
1379
1452
 
1380
1453
  // Node hit area
@@ -1906,13 +1979,38 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1906
1979
  )}
1907
1980
  </button>
1908
1981
  )}
1982
+
1983
+ {/* Show/hide cluster labels toggle */}
1984
+ <button
1985
+ onClick={() => setShowClusters((v) => !v)}
1986
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium backdrop-blur-sm rounded-lg border shadow-sm transition-colors ${
1987
+ showClusters
1988
+ ? "bg-emerald-500 text-white border-emerald-500"
1989
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
1990
+ }`}
1991
+ title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
1992
+ >
1993
+ <svg
1994
+ className="w-3.5 h-3.5"
1995
+ viewBox="0 0 16 16"
1996
+ fill="none"
1997
+ stroke="currentColor"
1998
+ strokeWidth="1.5"
1999
+ >
2000
+ {/* Dashed circle with label lines — cluster overlay icon */}
2001
+ <circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
2002
+ <line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
2003
+ <line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
2004
+ </svg>
2005
+ <span className="hidden sm:inline">Clusters</span>
2006
+ </button>
1909
2007
  </div>
1910
2008
 
1911
- {/* Bottom-right info panel: stats + status colors + legend (hidden when timeline active) */}
2009
+ {/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */}
1912
2010
  {!timelineActive && (
1913
2011
  <div
1914
2012
  className="absolute bottom-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 text-xs text-zinc-400 transition-[right] duration-300 ease-out"
1915
- style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem" }}
2013
+ style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem", maxWidth: 320 }}
1916
2014
  >
1917
2015
  {stats && (
1918
2016
  <div className="text-zinc-500 mb-1.5">
@@ -1924,19 +2022,55 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
1924
2022
  {stats.prefixes.length === 1 ? " project" : " projects"}
1925
2023
  </div>
1926
2024
  )}
1927
- <div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5">
1928
- {["open", "in_progress", "blocked", "deferred", "closed"].map((status) => (
1929
- <span key={status} className="flex items-center gap-1">
1930
- <span
1931
- className="w-2 h-2 rounded-full"
1932
- style={{ backgroundColor: STATUS_COLORS[status] }}
1933
- />
1934
- <span className="text-zinc-500">{STATUS_LABELS[status]}</span>
1935
- </span>
2025
+ {/* Color mode segmented control */}
2026
+ <div className="hidden sm:flex bg-zinc-100 rounded-md overflow-hidden mb-1.5">
2027
+ {(["status", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
2028
+ <button
2029
+ key={mode}
2030
+ onClick={() => onColorModeChange?.(mode)}
2031
+ className={`flex-1 px-2 py-1 text-[10px] font-medium transition-colors ${
2032
+ colorMode === mode
2033
+ ? "bg-emerald-500 text-white"
2034
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-200/60"
2035
+ }`}
2036
+ >
2037
+ {COLOR_MODE_LABELS[mode]}
2038
+ </button>
1936
2039
  ))}
1937
2040
  </div>
2041
+ {/* Dynamic legend: status dots or person/prefix dots */}
2042
+ <div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5">
2043
+ {colorMode === "status" ? (
2044
+ <>
2045
+ {["open", "in_progress", "blocked", "deferred", "closed"].map((status) => (
2046
+ <span key={status} className="flex items-center gap-1">
2047
+ <span
2048
+ className="w-2 h-2 rounded-full"
2049
+ style={{ backgroundColor: STATUS_COLORS[status] }}
2050
+ />
2051
+ <span className="text-zinc-500">{STATUS_LABELS[status]}</span>
2052
+ </span>
2053
+ ))}
2054
+ </>
2055
+ ) : (
2056
+ <>
2057
+ {legendItems.map(({ label, color }) => (
2058
+ <span key={label} className="flex items-center gap-1">
2059
+ <span
2060
+ className="w-2 h-2 rounded-full flex-shrink-0"
2061
+ style={{ backgroundColor: color }}
2062
+ />
2063
+ <span className="text-zinc-500 truncate max-w-[80px]">{label}</span>
2064
+ </span>
2065
+ ))}
2066
+ </>
2067
+ )}
2068
+ </div>
1938
2069
  <div className="hidden sm:flex flex-col gap-0.5 text-zinc-400">
1939
- <span>Size = importance · Ring = project</span>
2070
+ <span>
2071
+ Size = importance · Ring = project
2072
+ {colorMode !== "status" && ` · Fill = ${COLOR_MODE_LABELS[colorMode].toLowerCase()}`}
2073
+ </span>
1940
2074
  </div>
1941
2075
  <span className="sm:hidden">Tap a node for details</span>
1942
2076
  </div>
package/lib/types.ts CHANGED
@@ -69,6 +69,19 @@ export interface GraphNode {
69
69
  _prevStatus?: string; // Previous status value before the change (for color transition)
70
70
  }
71
71
 
72
+ // ============================================================================
73
+ // Color mode for legend/node fill switching
74
+ // ============================================================================
75
+
76
+ export type ColorMode = "status" | "owner" | "assignee" | "prefix";
77
+
78
+ export const COLOR_MODE_LABELS: Record<ColorMode, string> = {
79
+ status: "Status",
80
+ owner: "Owner",
81
+ assignee: "Assignee",
82
+ prefix: "Prefix",
83
+ };
84
+
72
85
  export interface GraphLink {
73
86
  source: string;
74
87
  target: string;
@@ -137,6 +150,36 @@ export const PRIORITY_COLORS: Record<number, string> = {
137
150
  4: "#d4d4d8", // zinc-300
138
151
  };
139
152
 
153
+ // ============================================================================
154
+ // Catppuccin Latte accent palette
155
+ // ============================================================================
156
+
157
+ /** Catppuccin Latte accent colors — 14 visually distinct, reordered for max contrast between adjacent indices */
158
+ export const CATPPUCCIN_ACCENTS: string[] = [
159
+ "#d20f39", // Red
160
+ "#179299", // Teal
161
+ "#fe640b", // Peach
162
+ "#1e66f5", // Blue
163
+ "#40a02b", // Green
164
+ "#8839ef", // Mauve
165
+ "#df8e1d", // Yellow
166
+ "#209fb5", // Sapphire
167
+ "#ea76cb", // Pink
168
+ "#04a5e5", // Sky
169
+ "#e64553", // Maroon
170
+ "#7287fd", // Lavender
171
+ "#dd7878", // Flamingo
172
+ "#dc8a78", // Rosewater
173
+ ];
174
+
175
+ export const CATPPUCCIN_ACCENT_NAMES: string[] = [
176
+ "Red", "Teal", "Peach", "Blue", "Green", "Mauve", "Yellow",
177
+ "Sapphire", "Pink", "Sky", "Maroon", "Lavender", "Flamingo", "Rosewater",
178
+ ];
179
+
180
+ /** Catppuccin Latte Surface2 — used for "unassigned" / unknown values */
181
+ export const CATPPUCCIN_UNASSIGNED = "#acb0be";
182
+
140
183
  export const TYPE_ICONS: Record<string, string> = {
141
184
  bug: "\uD83D\uDC1B",
142
185
  feature: "\u2728",
@@ -190,6 +233,29 @@ export function getPrefixColor(prefix: string): string {
190
233
  return hashColor(prefix);
191
234
  }
192
235
 
236
+ /**
237
+ * Deterministically map a person handle/name to a Catppuccin Latte accent color.
238
+ * Uses FNV-1a hash modulo 14 to index into CATPPUCCIN_ACCENTS.
239
+ * Returns CATPPUCCIN_UNASSIGNED for undefined/null/empty strings.
240
+ */
241
+ export function getPersonColor(person: string | undefined): string {
242
+ if (!person) return CATPPUCCIN_UNASSIGNED;
243
+ let hash = 2166136261; // FNV offset basis
244
+ for (let i = 0; i < person.length; i++) {
245
+ hash ^= person.charCodeAt(i);
246
+ hash = (hash * 16777619) >>> 0;
247
+ }
248
+ return CATPPUCCIN_ACCENTS[hash % CATPPUCCIN_ACCENTS.length];
249
+ }
250
+
251
+ /**
252
+ * Deterministically map a prefix string to a Catppuccin Latte accent color.
253
+ * Same approach as getPersonColor but for project prefixes.
254
+ */
255
+ export function getCatppuccinPrefixColor(prefix: string): string {
256
+ return getPersonColor(prefix);
257
+ }
258
+
193
259
  // Backward-compatible exports — delegates to dynamic functions
194
260
  // so existing code using PREFIX_COLORS[prefix] || fallback still works.
195
261
  // Prefer using getPrefixColor() / getPrefixLabel() directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beads-map",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Interactive dependency graph viewer for beads (bd) issues",
5
5
  "keywords": [
6
6
  "beads",