heartbeads 0.4.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 (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,2493 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { forceCollide, forceRadial, forceX, forceY } from "d3-force";
13
+ import type { GraphNode, GraphLink, ColorMode } from "@/lib/types";
14
+ import {
15
+ STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS, PRIORITY_LABELS,
16
+ COLOR_MODE_LABELS, getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,
17
+ } from "@/lib/types";
18
+
19
+ // Lazy-load ForceGraph2D client-side (it requires window/document).
20
+ // We avoid next/dynamic because it wraps the component in a LoadableComponent
21
+ // that does NOT forward refs, breaking graphRef.current.centerAt/zoom/etc.
22
+ let _ForceGraph2DModule: React.ComponentType<any> | null = null;
23
+
24
+ type LayoutMode = "force" | "dag" | "radial" | "cluster" | "spread";
25
+ export interface BeadsGraphHandle {
26
+ focusNode: (node: GraphNode) => void;
27
+ }
28
+
29
+ interface BeadsGraphProps {
30
+ nodes: GraphNode[];
31
+ links: GraphLink[];
32
+ selectedNode: GraphNode | null;
33
+ hoveredNode: GraphNode | null;
34
+ onNodeClick: (node: GraphNode) => void;
35
+ onNodeHover: (node: GraphNode | null, x: number, y: number) => void;
36
+ onBackgroundClick: () => void;
37
+ onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void;
38
+ commentedNodeIds?: Map<string, number>;
39
+ claimedNodeAvatars?: Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>;
40
+ onAvatarHover?: (info: { handle: string; avatar?: string; claimedAt: string; did?: string; x: number; y: number } | null) => void;
41
+ timelineActive?: boolean;
42
+ stats?: { total: number; edges: number; prefixes: string[] };
43
+ /** When a right sidebar (NodeDetail, Comments, Activity) is open, shift bottom-right legend inward */
44
+ sidebarOpen?: boolean;
45
+ /** Set of epic IDs that are currently collapsed */
46
+ collapsedEpicIds?: Set<string>;
47
+ /** Collapse all epics at once */
48
+ onCollapseAll?: () => void;
49
+ /** Expand all epics at once */
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;
55
+ /** Whether to auto-zoom to fit all nodes after data updates and layout changes */
56
+ autoFit?: boolean;
57
+ /** Callback to toggle auto-fit */
58
+ onAutoFitToggle?: () => void;
59
+ /** Node ID to show a pulsing ripple on (most recently active node) */
60
+ pulseNodeId?: string | null;
61
+ /** Whether pulse animation is enabled */
62
+ showPulse?: boolean;
63
+ /** Callback to toggle pulse animation */
64
+ onShowPulseToggle?: () => void;
65
+ /** When set, only show this epic and its connected subgraph */
66
+ focusedEpicId?: string | null;
67
+ /** Callback to exit focused epic mode */
68
+ onExitFocusedEpic?: () => void;
69
+ /** Whether the viewport is mobile (<=768px) — enables double-tap detection */
70
+ isMobile?: boolean;
71
+ /** Callback for double-tap on a node (mobile context menu) */
72
+ onNodeDoubleTap?: (node: GraphNode, x: number, y: number) => void;
73
+ }
74
+
75
+ // Node size calculation
76
+ function getNodeSize(node: GraphNode): number {
77
+ const MIN_SIZE = 5;
78
+ const MAX_SIZE = 22;
79
+ const connections = node.blockerCount + node.dependentCount;
80
+
81
+ // Epics get a base boost
82
+ let score = connections;
83
+ if (node.issueType === "epic") score += 3;
84
+
85
+ // Normalize: 0 connections -> MIN, 6+ -> MAX
86
+ const normalized = Math.min(score / 6, 1);
87
+ return MIN_SIZE + normalized * (MAX_SIZE - MIN_SIZE);
88
+ }
89
+
90
+ // Module-level color mode tracker (synced from component via useEffect)
91
+ let _currentColorMode: ColorMode = "status";
92
+
93
+ // Get color based on current color mode
94
+ function getNodeColor(node: GraphNode): string {
95
+ switch (_currentColorMode) {
96
+ case "priority":
97
+ return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2];
98
+ case "owner":
99
+ return getPersonColor(node.createdBy);
100
+ case "assignee":
101
+ return getPersonColor(node.assignee);
102
+ case "prefix":
103
+ return getCatppuccinPrefixColor(node.prefix);
104
+ case "status":
105
+ default:
106
+ return STATUS_COLORS[node.status] || STATUS_COLORS.open;
107
+ }
108
+ }
109
+
110
+ // Get prefix color for the outer ring — uses Catppuccin palette for consistency
111
+ function getPrefixRingColor(node: GraphNode): string {
112
+ return getCatppuccinPrefixColor(node.prefix);
113
+ }
114
+
115
+ // Animation duration constants
116
+ const SPAWN_DURATION = 500; // ms for pop-in animation
117
+ const REMOVE_DURATION = 400; // ms for shrink-out animation
118
+ const CHANGE_DURATION = 800; // ms for status change ripple
119
+
120
+ /**
121
+ * easeOutBack: overshoots slightly then settles — gives "pop" feel.
122
+ */
123
+ function easeOutBack(t: number): number {
124
+ const c1 = 1.70158;
125
+ const c3 = c1 + 1;
126
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
127
+ }
128
+
129
+ /**
130
+ * easeOutQuad: smooth deceleration.
131
+ */
132
+ function easeOutQuad(t: number): number {
133
+ return 1 - (1 - t) * (1 - t);
134
+ }
135
+
136
+ // --- Module-level avatar image cache for canvas rendering ---
137
+ const avatarImageCache = new Map<
138
+ string,
139
+ HTMLImageElement | "loading" | "failed"
140
+ >();
141
+
142
+ function getAvatarImage(
143
+ url: string,
144
+ onLoad: () => void
145
+ ): HTMLImageElement | null {
146
+ const cached = avatarImageCache.get(url);
147
+ if (cached === "loading" || cached === "failed") return null;
148
+ if (cached) return cached;
149
+
150
+ avatarImageCache.set(url, "loading");
151
+ const img = new Image();
152
+ img.onload = () => {
153
+ avatarImageCache.set(url, img);
154
+ onLoad();
155
+ };
156
+ img.onerror = () => {
157
+ avatarImageCache.set(url, "failed");
158
+ };
159
+ img.src = url;
160
+ return null;
161
+ }
162
+
163
+ function drawAvatarFallback(
164
+ ctx: CanvasRenderingContext2D,
165
+ x: number,
166
+ y: number,
167
+ radius: number,
168
+ handle: string,
169
+ globalScale: number
170
+ ) {
171
+ ctx.beginPath();
172
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
173
+ ctx.fillStyle = "#e4e4e7"; // zinc-200
174
+ ctx.fill();
175
+
176
+ const letter = handle.replace("@", "").charAt(0).toUpperCase();
177
+ const fontSize = Math.min(7, Math.max(3, radius * 1.3));
178
+ ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`;
179
+ ctx.textAlign = "center";
180
+ ctx.textBaseline = "middle";
181
+ ctx.fillStyle = "#71717a"; // zinc-500
182
+ ctx.fillText(letter, x, y + 0.3);
183
+ }
184
+
185
+ /**
186
+ * Compute connected subgraph via BFS (depth 2) - pure function, no React state
187
+ */
188
+ function computeConnectedNodes(
189
+ targetNodeId: string,
190
+ links: GraphLink[]
191
+ ): Set<string> {
192
+ const connected = new Set<string>([targetNodeId]);
193
+ const queue = [{ id: targetNodeId, depth: 0 }];
194
+
195
+ while (queue.length > 0) {
196
+ const { id, depth } = queue.shift()!;
197
+ if (depth >= 2) continue;
198
+
199
+ for (const link of links) {
200
+ const src =
201
+ typeof link.source === "object"
202
+ ? (link.source as any).id
203
+ : link.source;
204
+ const tgt =
205
+ typeof link.target === "object"
206
+ ? (link.target as any).id
207
+ : link.target;
208
+
209
+ if (src === id && !connected.has(tgt)) {
210
+ connected.add(tgt);
211
+ queue.push({ id: tgt, depth: depth + 1 });
212
+ }
213
+ if (tgt === id && !connected.has(src)) {
214
+ connected.add(src);
215
+ queue.push({ id: src, depth: depth + 1 });
216
+ }
217
+ }
218
+ }
219
+
220
+ return connected;
221
+ }
222
+
223
+ /**
224
+ * Imperceptible zoom trick to force canvas redraw without re-heating simulation.
225
+ * Borrowed from the reference beads map (graph.js refreshGraph()).
226
+ */
227
+ function refreshGraph(graphRef: React.RefObject<any>) {
228
+ const graph = graphRef.current;
229
+ if (!graph) return;
230
+ const currentZoom = graph.zoom();
231
+ if (typeof currentZoom !== "number" || isNaN(currentZoom)) return;
232
+ graph.zoom(currentZoom * 1.000001, 0);
233
+ requestAnimationFrame(() => {
234
+ if (graphRef.current) graphRef.current.zoom(currentZoom, 0);
235
+ });
236
+ }
237
+
238
+
239
+
240
+ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsGraph({
241
+ nodes,
242
+ links,
243
+ selectedNode,
244
+ hoveredNode,
245
+ onNodeClick,
246
+ onNodeHover,
247
+ onBackgroundClick,
248
+ onNodeRightClick,
249
+ commentedNodeIds,
250
+ claimedNodeAvatars,
251
+ onAvatarHover,
252
+ timelineActive,
253
+ stats,
254
+ sidebarOpen,
255
+ collapsedEpicIds,
256
+ onCollapseAll,
257
+ onExpandAll,
258
+ colorMode = "status",
259
+ onColorModeChange,
260
+ autoFit = true,
261
+ onAutoFitToggle,
262
+ pulseNodeId,
263
+ showPulse = true,
264
+ onShowPulseToggle,
265
+ focusedEpicId,
266
+ onExitFocusedEpic,
267
+ isMobile,
268
+ onNodeDoubleTap,
269
+ }, ref) {
270
+ const graphRef = useRef<any>(null);
271
+ const containerRef = useRef<HTMLDivElement>(null);
272
+ const minimapCanvasRef = useRef<HTMLCanvasElement>(null);
273
+ const minimapRafRef = useRef<number>(0);
274
+ const redrawMinimapRef = useRef<() => void>(() => {});
275
+ const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
276
+ const initialLayoutApplied = useRef(false);
277
+
278
+ // Double-tap detection for mobile context menu
279
+ const lastTapRef = useRef<{ nodeId: string; time: number } | null>(null);
280
+ const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
281
+
282
+ // Cleanup tap timeout on unmount
283
+ useEffect(() => {
284
+ return () => {
285
+ if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current);
286
+ };
287
+ }, []);
288
+
289
+ // Minimap dimensions (resizable via drag)
290
+ const [minimapSize, setMinimapSize] = useState({ w: 160, h: 120 });
291
+ const MINIMAP_W = minimapSize.w;
292
+ const MINIMAP_H = minimapSize.h;
293
+ const MINIMAP_PAD = 8; // internal padding so dots aren't clipped at edges
294
+
295
+ // Minimap resize drag state
296
+ const minimapDragRef = useRef<{
297
+ edge: "top" | "right" | "top-right";
298
+ startX: number;
299
+ startY: number;
300
+ startW: number;
301
+ startH: number;
302
+ } | null>(null);
303
+
304
+ // Lazy-load ForceGraph2D on the client (preserves ref forwarding)
305
+ const [ForceGraph2D, setForceGraph2D] =
306
+ useState<React.ComponentType<any> | null>(_ForceGraph2DModule);
307
+
308
+ useEffect(() => {
309
+ if (_ForceGraph2DModule) return; // already loaded
310
+ import("react-force-graph-2d").then((mod) => {
311
+ _ForceGraph2DModule = mod.default || mod;
312
+ setForceGraph2D(() => _ForceGraph2DModule);
313
+ });
314
+ }, []);
315
+
316
+ // Layout mode: "force" (physics-based) or "dag" (topological top-down)
317
+ const [layoutMode, setLayoutMode] = useState<LayoutMode>("dag");
318
+
319
+ // Whether to show hierarchical cluster circles/labels when zoomed out
320
+ const [showClusters, setShowClusters] = useState(true);
321
+
322
+
323
+
324
+
325
+ // Use refs for transient visual state to avoid re-rendering the ForceGraph
326
+ // component (which causes simulation re-heat and the "jitter" on hover).
327
+ const selectedNodeRef = useRef<GraphNode | null>(selectedNode);
328
+ const hoveredNodeRef = useRef<GraphNode | null>(hoveredNode);
329
+ const connectedNodesRef = useRef<Set<string>>(new Set());
330
+ const commentedNodeIdsRef = useRef<Map<string, number>>(commentedNodeIds || new Map());
331
+ const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>>(
332
+ claimedNodeAvatars || new Map()
333
+ );
334
+ // Color mode ref for paintNode (which has [] deps) to read current color mode
335
+ const colorModeRef = useRef<ColorMode>(colorMode);
336
+ // Pulse node ref: which node to show ripple animation on
337
+ const pulseNodeIdRef = useRef<string | null>(pulseNodeId || null);
338
+ const showPulseRef = useRef<boolean>(showPulse);
339
+
340
+ // Callback ref for refreshing graph when avatar images finish loading
341
+ const avatarRefreshRef = useRef<() => void>(() => {});
342
+ avatarRefreshRef.current = () => refreshGraph(graphRef);
343
+
344
+ // Ref for avatar hover callback (avoids stale closures in mousemove handler)
345
+ const onAvatarHoverRef = useRef(onAvatarHover);
346
+ onAvatarHoverRef.current = onAvatarHover;
347
+
348
+ // Track which avatar is currently hovered to avoid redundant callbacks
349
+ const hoveredAvatarNodeRef = useRef<string | null>(null);
350
+
351
+ // Track last mouse position for passing coordinates with onNodeHover
352
+ const lastMouseRef = useRef({ x: 0, y: 0 });
353
+
354
+ // Ref for current viewNodes (used by mousemove handler to respect epics view)
355
+ const viewNodesRef = useRef<GraphNode[]>(nodes);
356
+
357
+ // Cluster data ref for semantic zoom: maps epic (parent) IDs to their
358
+ // member node IDs so we can compute centroids and draw cluster labels
359
+ // when zoomed out far enough.
360
+ type ClusterInfo = {
361
+ parentId: string;
362
+ title: string;
363
+ prefix: string;
364
+ memberIds: string[];
365
+ };
366
+ const clustersRef = useRef<ClusterInfo[]>([]);
367
+
368
+ // Compute collapsed view when any epics are collapsed via collapsedEpicIds.
369
+ // Builds a child->parent map from parent-child dependencies and hierarchical IDs,
370
+ // then removes child nodes and remaps their links to the parent epic.
371
+ // Must be declared BEFORE effects that reference viewNodes/viewLinks.
372
+ const { viewNodes, viewLinks } = useMemo(() => {
373
+ let currentNodes = nodes;
374
+ let currentLinks = links;
375
+
376
+ // === PHASE 1: Epic focus mode ===
377
+ // When focused on an epic, filter to only the epic's subgraph
378
+ if (focusedEpicId) {
379
+ // Build child->parent map
380
+ const childToParent = new Map<string, string>();
381
+ for (const link of currentLinks) {
382
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
383
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
384
+ if (link.type === "parent-child") {
385
+ childToParent.set(tgt, src);
386
+ }
387
+ }
388
+ const nodeIdSet = new Set(currentNodes.map((n) => n.id));
389
+ for (const node of currentNodes) {
390
+ if (!childToParent.has(node.id) && node.id.includes(".")) {
391
+ const parentId = node.id.split(".")[0];
392
+ if (nodeIdSet.has(parentId)) {
393
+ childToParent.set(node.id, parentId);
394
+ }
395
+ }
396
+ }
397
+
398
+ // Collect epic + direct children
399
+ const subgraphIds = new Set<string>();
400
+ subgraphIds.add(focusedEpicId);
401
+ for (const [childId, parentId] of childToParent) {
402
+ if (parentId === focusedEpicId) {
403
+ subgraphIds.add(childId);
404
+ }
405
+ }
406
+
407
+ // Add 1-hop neighbors connected via blocks/relates_to links
408
+ for (const link of currentLinks) {
409
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
410
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
411
+ if (link.type !== "parent-child") {
412
+ if (subgraphIds.has(src) && nodeIdSet.has(tgt)) subgraphIds.add(tgt);
413
+ if (subgraphIds.has(tgt) && nodeIdSet.has(src)) subgraphIds.add(src);
414
+ }
415
+ }
416
+
417
+ currentNodes = currentNodes.filter((n) => subgraphIds.has(n.id));
418
+ currentLinks = currentLinks.filter((link) => {
419
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
420
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
421
+ return subgraphIds.has(src) && subgraphIds.has(tgt);
422
+ });
423
+ }
424
+
425
+ // === PHASE 2: Collapse mode ===
426
+ if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
427
+
428
+ // Build child->parent map from parent-child links
429
+ const childToParent = new Map<string, string>();
430
+ for (const link of currentLinks) {
431
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
432
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
433
+ if (link.type === "parent-child") {
434
+ // source is parent, target is child
435
+ childToParent.set(tgt, src);
436
+ }
437
+ }
438
+ // Fallback: infer from hierarchical IDs (e.g., "myproject-3r3.1" -> parent "myproject-3r3")
439
+ const nodeIds = new Set(currentNodes.map((n) => n.id));
440
+ for (const node of currentNodes) {
441
+ if (!childToParent.has(node.id) && node.id.includes(".")) {
442
+ const parentId = node.id.split(".")[0];
443
+ if (nodeIds.has(parentId)) {
444
+ childToParent.set(node.id, parentId);
445
+ }
446
+ }
447
+ }
448
+
449
+ // Collapse children whose parent is in collapsedEpicIds
450
+ const childIds = new Set<string>();
451
+ for (const [childId, parentId] of childToParent) {
452
+ if (collapsedEpicIds.has(parentId)) {
453
+ childIds.add(childId);
454
+ }
455
+ }
456
+
457
+ if (childIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
458
+
459
+ // Also build a filtered childToParent for only the collapsed children (for link remapping)
460
+ const collapsedChildToParent = new Map<string, string>();
461
+ for (const childId of childIds) {
462
+ collapsedChildToParent.set(childId, childToParent.get(childId)!);
463
+ }
464
+
465
+ // Accumulate collapsed children count and extra connections onto parent nodes
466
+ const collapsedCounts = new Map<string, number>();
467
+ const extraBlockerCount = new Map<string, number>();
468
+ const extraDependentCount = new Map<string, number>();
469
+ for (const [childId, parentId] of collapsedChildToParent) {
470
+ collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1);
471
+ const child = currentNodes.find((n) => n.id === childId);
472
+ if (child) {
473
+ extraBlockerCount.set(parentId, (extraBlockerCount.get(parentId) || 0) + child.blockerCount);
474
+ extraDependentCount.set(parentId, (extraDependentCount.get(parentId) || 0) + child.dependentCount);
475
+ }
476
+ }
477
+
478
+ // Filter nodes: remove collapsed children, augment their parents
479
+ const filteredNodes: GraphNode[] = currentNodes
480
+ .filter((n) => !childIds.has(n.id))
481
+ .map((n) => ({
482
+ ...n,
483
+ blockerCount: n.blockerCount + (extraBlockerCount.get(n.id) || 0),
484
+ dependentCount: n.dependentCount + (extraDependentCount.get(n.id) || 0),
485
+ collapsedCount: collapsedCounts.get(n.id) || 0,
486
+ }));
487
+
488
+ // Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links
489
+ const remappedLinks: GraphLink[] = [];
490
+ const linkSeen = new Set<string>();
491
+ for (const link of currentLinks) {
492
+ let src = typeof link.source === "object" ? (link.source as any).id : link.source;
493
+ let tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
494
+ // Drop parent-child links where the child is collapsed
495
+ if (link.type === "parent-child" && childIds.has(tgt)) continue;
496
+ // Remap collapsed child endpoints to their parent
497
+ src = collapsedChildToParent.get(src) || src;
498
+ tgt = collapsedChildToParent.get(tgt) || tgt;
499
+ if (src === tgt) continue; // self-link after collapse
500
+ const key = `${src}->${tgt}:${link.type}`;
501
+ if (linkSeen.has(key)) continue;
502
+ linkSeen.add(key);
503
+ remappedLinks.push({ source: src, target: tgt, type: link.type });
504
+ }
505
+
506
+ return { viewNodes: filteredNodes, viewLinks: remappedLinks };
507
+ }, [nodes, links, collapsedEpicIds, focusedEpicId]);
508
+
509
+ // Keep viewNodesRef in sync for mousemove avatar hit-testing
510
+ viewNodesRef.current = viewNodes;
511
+
512
+ // Build cluster info for semantic zoom: group nodes by parent epic.
513
+ // This is used by onRenderFramePost to draw cluster labels when zoomed out.
514
+ useEffect(() => {
515
+ // Build child→parent map (same logic as epics useMemo above)
516
+ const childToParent = new Map<string, string>();
517
+ for (const link of links) {
518
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
519
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
520
+ if (link.type === "parent-child") {
521
+ childToParent.set(tgt, src);
522
+ }
523
+ }
524
+ const nodeIds = new Set(nodes.map((n) => n.id));
525
+ for (const node of nodes) {
526
+ if (!childToParent.has(node.id) && node.id.includes(".")) {
527
+ const parentId = node.id.split(".")[0];
528
+ if (nodeIds.has(parentId)) {
529
+ childToParent.set(node.id, parentId);
530
+ }
531
+ }
532
+ }
533
+
534
+ // Group children under parents
535
+ const parentToChildren = new Map<string, string[]>();
536
+ for (const [childId, parentId] of childToParent) {
537
+ const arr = parentToChildren.get(parentId) || [];
538
+ arr.push(childId);
539
+ parentToChildren.set(parentId, arr);
540
+ }
541
+
542
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
543
+ const clusters: ClusterInfo[] = [];
544
+
545
+ // Only epic clusters: parent + its children (skip standalone/disconnected nodes)
546
+ for (const [parentId, childIds] of parentToChildren) {
547
+ const parent = nodeMap.get(parentId);
548
+ if (!parent) continue;
549
+ clusters.push({
550
+ parentId,
551
+ title: parent.title || parentId,
552
+ prefix: parent.prefix,
553
+ memberIds: [parentId, ...childIds],
554
+ });
555
+ }
556
+
557
+ clustersRef.current = clusters;
558
+ }, [nodes, links]);
559
+
560
+ // Compute dynamic legend items based on color mode and visible nodes
561
+ const legendItems = useMemo(() => {
562
+ if (colorMode === "status" || colorMode === "priority") return []; // handled by static rendering
563
+
564
+ const items = new Map<string, string>(); // label -> color
565
+
566
+ for (const node of viewNodes) {
567
+ switch (colorMode) {
568
+ case "owner": {
569
+ const key = node.createdBy || undefined;
570
+ items.set(key || "Unassigned", getPersonColor(key));
571
+ break;
572
+ }
573
+ case "assignee": {
574
+ const key = node.assignee || undefined;
575
+ items.set(key || "Unassigned", getPersonColor(key));
576
+ break;
577
+ }
578
+ case "prefix": {
579
+ items.set(getPrefixLabel(node.prefix), getCatppuccinPrefixColor(node.prefix));
580
+ break;
581
+ }
582
+ }
583
+ }
584
+
585
+ // Sort: "Unassigned" last, others alphabetically
586
+ return Array.from(items.entries())
587
+ .sort(([a], [b]) => {
588
+ if (a === "Unassigned") return 1;
589
+ if (b === "Unassigned") return -1;
590
+ return a.localeCompare(b);
591
+ })
592
+ .map(([label, color]) => ({ label, color }));
593
+ }, [colorMode, viewNodes]);
594
+
595
+ // Sync props into refs and trigger canvas redraw (not React re-render).
596
+ // Also schedules a minimap redraw so highlight state is synced there too.
597
+ // Uses viewLinks (respects epic collapse) for connected subgraph computation.
598
+ useEffect(() => {
599
+ selectedNodeRef.current = selectedNode;
600
+
601
+ // Recompute connected subgraph
602
+ const target = hoveredNodeRef.current || selectedNode;
603
+ if (target) {
604
+ connectedNodesRef.current = computeConnectedNodes(target.id, viewLinks);
605
+ } else {
606
+ connectedNodesRef.current = new Set();
607
+ }
608
+
609
+ refreshGraph(graphRef);
610
+ // Minimap picks up highlight from refs — schedule redraw
611
+ cancelAnimationFrame(minimapRafRef.current);
612
+ minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
613
+ }, [selectedNode, viewLinks]);
614
+
615
+ useEffect(() => {
616
+ hoveredNodeRef.current = hoveredNode;
617
+
618
+ // Recompute connected subgraph
619
+ const target = hoveredNode || selectedNodeRef.current;
620
+ if (target) {
621
+ connectedNodesRef.current = computeConnectedNodes(target.id, viewLinks);
622
+ } else {
623
+ connectedNodesRef.current = new Set();
624
+ }
625
+
626
+ refreshGraph(graphRef);
627
+ // Minimap picks up highlight from refs — schedule redraw
628
+ cancelAnimationFrame(minimapRafRef.current);
629
+ minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
630
+ }, [hoveredNode, viewLinks]);
631
+
632
+ // Sync commentedNodeIds ref and trigger canvas redraw
633
+ useEffect(() => {
634
+ commentedNodeIdsRef.current = commentedNodeIds || new Map();
635
+ refreshGraph(graphRef);
636
+ }, [commentedNodeIds]);
637
+
638
+ useEffect(() => {
639
+ claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map();
640
+ refreshGraph(graphRef);
641
+ }, [claimedNodeAvatars]);
642
+
643
+ // Sync color mode to module-level variable and ref, trigger canvas + minimap redraw
644
+ useEffect(() => {
645
+ colorModeRef.current = colorMode;
646
+ _currentColorMode = colorMode;
647
+ refreshGraph(graphRef);
648
+ minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
649
+ }, [colorMode]);
650
+
651
+ // Sync pulse node ref
652
+ useEffect(() => {
653
+ pulseNodeIdRef.current = pulseNodeId || null;
654
+ showPulseRef.current = showPulse;
655
+ refreshGraph(graphRef);
656
+ }, [pulseNodeId, showPulse]);
657
+
658
+ // Avatar hover detection: mousemove on container, hit-test against avatar positions
659
+ useEffect(() => {
660
+ const container = containerRef.current;
661
+ if (!container) return;
662
+
663
+ const handleMouseMove = (e: MouseEvent) => {
664
+ // Track last mouse position for onNodeHover coordinates
665
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
666
+
667
+ const fg = graphRef.current;
668
+ const cb = onAvatarHoverRef.current;
669
+ if (!fg || !cb) return;
670
+
671
+ const claimedMap = claimedNodeAvatarsRef.current;
672
+ if (claimedMap.size === 0) {
673
+ if (hoveredAvatarNodeRef.current) {
674
+ hoveredAvatarNodeRef.current = null;
675
+ cb(null);
676
+ }
677
+ return;
678
+ }
679
+
680
+ // Convert screen coords to graph coords
681
+ const rect = container.getBoundingClientRect();
682
+ const screenX = e.clientX - rect.left;
683
+ const screenY = e.clientY - rect.top;
684
+ let graphCoords: { x: number; y: number };
685
+ try {
686
+ graphCoords = fg.screen2GraphCoords(screenX, screenY);
687
+ } catch {
688
+ return;
689
+ }
690
+
691
+ // Hit-test against each claimed node's avatar position
692
+ const globalScale = fg.zoom() || 1;
693
+ const avatarRadius = Math.max(4, 10 / globalScale);
694
+
695
+ for (const node of viewNodesRef.current) {
696
+ const n = node as any;
697
+ if (n.x == null || n.y == null) continue;
698
+ const claim = claimedMap.get(node.id);
699
+ if (!claim) continue;
700
+
701
+ const size = getNodeSize(node);
702
+ const avatarX = n.x + size * 0.7;
703
+ const avatarY = n.y + size * 0.7;
704
+ const dx = graphCoords.x - avatarX;
705
+ const dy = graphCoords.y - avatarY;
706
+
707
+ if (dx * dx + dy * dy <= avatarRadius * avatarRadius) {
708
+ if (hoveredAvatarNodeRef.current !== node.id) {
709
+ hoveredAvatarNodeRef.current = node.id;
710
+ cb({ handle: claim.handle, avatar: claim.avatar, claimedAt: claim.claimedAt, did: claim.did, x: e.clientX, y: e.clientY });
711
+ }
712
+ return;
713
+ }
714
+ }
715
+
716
+ // No avatar hit
717
+ if (hoveredAvatarNodeRef.current) {
718
+ hoveredAvatarNodeRef.current = null;
719
+ cb(null);
720
+ }
721
+ };
722
+
723
+ container.addEventListener("mousemove", handleMouseMove);
724
+ return () => container.removeEventListener("mousemove", handleMouseMove);
725
+ }, []);
726
+
727
+ // Track dimensions
728
+ useEffect(() => {
729
+ const updateDimensions = () => {
730
+ if (containerRef.current) {
731
+ const rect = containerRef.current.getBoundingClientRect();
732
+ setDimensions({ width: rect.width, height: rect.height });
733
+ }
734
+ };
735
+
736
+ updateDimensions();
737
+ window.addEventListener("resize", updateDimensions);
738
+ return () => window.removeEventListener("resize", updateDimensions);
739
+ }, []);
740
+
741
+
742
+
743
+ // Focus the node: animate centerAt + zoom, then select it
744
+ const focusNode = useCallback(
745
+ (node: GraphNode) => {
746
+ const fg = graphRef.current;
747
+ if (!fg) return;
748
+
749
+ // The force simulation mutates node objects in-place, so the nodes
750
+ // prop array already has x/y coordinates set by the simulation.
751
+ // Note: graphRef.current.graphData() is NOT available - the React
752
+ // wrapper only exposes specific methods (centerAt, zoom, etc).
753
+ const graphNode = viewNodes.find((n) => n.id === node.id);
754
+ if (!graphNode || graphNode.x === undefined || graphNode.y === undefined)
755
+ return;
756
+
757
+ // Animate: center on node then zoom in
758
+ fg.centerAt(graphNode.x, graphNode.y, 500);
759
+ fg.zoom(2.5, 500);
760
+
761
+ // Select the node (triggers highlight via parent)
762
+ onNodeClick(node);
763
+ },
764
+ [onNodeClick, viewNodes]
765
+ );
766
+
767
+ // Expose focusNode to parent via ref
768
+ useImperativeHandle(ref, () => ({ focusNode }), [focusNode]);
769
+
770
+
771
+
772
+ // Single unified effect for force configuration.
773
+ // Runs on initial load AND when switching between Force / DAG layouts,
774
+ // so the initial graph looks the same as toggling DAG → Force.
775
+ useEffect(() => {
776
+ const fg = graphRef.current;
777
+ if (!fg || viewNodes.length === 0) return;
778
+
779
+ // Helper: clear custom forces that only specific layouts use.
780
+ // Must be called at the start of every branch to prevent stale forces.
781
+ const clearCustomForces = () => {
782
+ fg.d3Force("radial", null);
783
+ fg.d3Force("x", null);
784
+ fg.d3Force("y", null);
785
+ };
786
+
787
+ // Helper: clear fixed positions left over from DAG mode.
788
+ const clearFixedPositions = () => {
789
+ viewNodes.forEach((node: any) => {
790
+ delete node.fx;
791
+ delete node.fy;
792
+ });
793
+ };
794
+
795
+ if (layoutMode === "dag") {
796
+ // DAG mode: topological layers (td) + spread-like horizontal spacing.
797
+ // Strong charge repulsion pushes siblings apart within each layer,
798
+ // while dagMode handles vertical ordering.
799
+ clearCustomForces();
800
+ fg.d3Force("charge")?.strength(-250).distanceMax(500);
801
+ fg.d3Force("link")?.distance(120).strength(0.3);
802
+ fg.d3Force("center")?.strength(0.015);
803
+ // Collision prevents overlap within layers
804
+ fg.d3Force("collision",
805
+ forceCollide()
806
+ .radius((node: any) => getNodeSize(node as GraphNode) + 8)
807
+ .strength(0.8)
808
+ );
809
+
810
+ } else if (layoutMode === "radial") {
811
+ // Radial layout: concentric rings by dependency depth.
812
+ // Compute BFS depth from root nodes (no incoming blocks edges).
813
+ clearCustomForces();
814
+ clearFixedPositions();
815
+
816
+ const incoming = new Map<string, string[]>();
817
+ for (const link of viewLinks) {
818
+ if (link.type === "parent-child") continue;
819
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
820
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
821
+ if (!incoming.has(tgt)) incoming.set(tgt, []);
822
+ incoming.get(tgt)!.push(src);
823
+ }
824
+ const depthMap = new Map<string, number>();
825
+ const queue: string[] = [];
826
+ viewNodes.forEach((n: any) => {
827
+ if (!incoming.has(n.id)) { depthMap.set(n.id, 0); queue.push(n.id); }
828
+ });
829
+ let qi = 0;
830
+ while (qi < queue.length) {
831
+ const id = queue[qi++];
832
+ const d = depthMap.get(id)!;
833
+ for (const link of viewLinks) {
834
+ if (link.type === "parent-child") continue;
835
+ const src = typeof link.source === "object" ? (link.source as any).id : link.source;
836
+ const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
837
+ if (src === id && !depthMap.has(tgt)) {
838
+ depthMap.set(tgt, d + 1);
839
+ queue.push(tgt);
840
+ }
841
+ }
842
+ }
843
+ // Store depth transiently on each node for the radial force accessor
844
+ viewNodes.forEach((n: any) => { n._depth = depthMap.get(n.id) ?? 0; });
845
+
846
+ // Scale ring spacing by node count so rings don't overlap
847
+ const maxDepth = Math.max(1, ...Array.from(depthMap.values()));
848
+ const ringSpacing = Math.max(200, viewNodes.length * 4);
849
+
850
+ fg.d3Force("charge")?.strength(-300).distanceMax(800);
851
+ fg.d3Force("link")?.distance(150).strength(0.15);
852
+ fg.d3Force("center")?.strength(0); // no center pull — radial handles centering
853
+ fg.d3Force("radial",
854
+ forceRadial(
855
+ (node: any) => ((node as any)._depth || 0) * ringSpacing,
856
+ 0, 0
857
+ ).strength(0.8)
858
+ );
859
+ fg.d3Force("x", null); // let radial + charge handle positioning
860
+ fg.d3Force("y", null);
861
+ fg.d3Force("collision",
862
+ forceCollide()
863
+ .radius((node: any) => getNodeSize(node as GraphNode) + 10)
864
+ .strength(0.9)
865
+ );
866
+
867
+ } else if (layoutMode === "cluster") {
868
+ // Cluster layout: group nodes by project prefix.
869
+ clearCustomForces();
870
+ clearFixedPositions();
871
+
872
+ const prefixes = [...new Set(viewNodes.map((n: any) => (n as GraphNode).prefix))];
873
+ // Scale cluster separation by total node count — more nodes need more space
874
+ const radius = Math.max(400, viewNodes.length * 5, prefixes.length * 150);
875
+ const prefixCenters = new Map<string, { x: number; y: number }>();
876
+ prefixes.forEach((prefix, i) => {
877
+ const angle = (2 * Math.PI * i) / prefixes.length - Math.PI / 2;
878
+ prefixCenters.set(prefix, {
879
+ x: Math.cos(angle) * radius,
880
+ y: Math.sin(angle) * radius,
881
+ });
882
+ });
883
+
884
+ fg.d3Force("charge")?.strength(-200).distanceMax(600);
885
+ fg.d3Force("link")?.distance(100).strength(0.15);
886
+ fg.d3Force("center")?.strength(0); // no center pull — x/y handle positioning
887
+ fg.d3Force("x",
888
+ forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.5)
889
+ );
890
+ fg.d3Force("y",
891
+ forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.5)
892
+ );
893
+ fg.d3Force("collision",
894
+ forceCollide()
895
+ .radius((node: any) => getNodeSize(node as GraphNode) + 10)
896
+ .strength(0.9)
897
+ );
898
+
899
+ } else if (layoutMode === "spread") {
900
+ // Spread layout: like force but maximally spaced for readability.
901
+ clearCustomForces();
902
+ clearFixedPositions();
903
+
904
+ fg.d3Force("charge")?.strength(-300).distanceMax(500);
905
+ fg.d3Force("link")?.distance(180).strength(0.4);
906
+ fg.d3Force("center")?.strength(0.02);
907
+ fg.d3Force("collision",
908
+ forceCollide()
909
+ .radius((node: any) => getNodeSize(node as GraphNode) + 8)
910
+ .strength(0.8)
911
+ );
912
+
913
+ } else {
914
+ // Force mode: full physics (default)
915
+ clearCustomForces();
916
+ clearFixedPositions();
917
+
918
+ fg.d3Force("charge")?.strength(-180).distanceMax(400);
919
+ fg.d3Force("link")
920
+ ?.distance((link: any) => {
921
+ const srcConnections =
922
+ (link.source?.blockerCount || 0) +
923
+ (link.source?.dependentCount || 0);
924
+ const tgtConnections =
925
+ (link.target?.blockerCount || 0) +
926
+ (link.target?.dependentCount || 0);
927
+ const avgConnections = (srcConnections + tgtConnections) / 2;
928
+ return avgConnections > 4 ? 90 : 120;
929
+ })
930
+ .strength(0.6);
931
+ fg.d3Force("center")?.strength(0.03);
932
+ fg.d3Force("collision",
933
+ forceCollide()
934
+ .radius((node: any) => getNodeSize(node as GraphNode) + 6)
935
+ .strength(0.7)
936
+ );
937
+ }
938
+
939
+ // Re-heat simulation so new forces take effect immediately
940
+ fg.d3ReheatSimulation();
941
+
942
+ // Fit to view after layout settles (only if auto-fit is enabled)
943
+ let timer: ReturnType<typeof setTimeout> | undefined;
944
+ if (autoFit) {
945
+ const delay = initialLayoutApplied.current ? 600 : 1000;
946
+ timer = setTimeout(() => {
947
+ if (graphRef.current) graphRef.current.zoomToFit(400, 60);
948
+ }, delay);
949
+ }
950
+
951
+ initialLayoutApplied.current = true;
952
+
953
+ return () => { if (timer) clearTimeout(timer); };
954
+ }, [layoutMode, viewNodes, viewLinks, autoFit]);
955
+
956
+ // Bootstrap trick: start in DAG to spread nodes into good positions,
957
+ // then auto-switch to Force mode. This replicates the exact code path
958
+ // that makes DAG → Force look great (nodes inherit spread-out positions).
959
+ const bootstrapped = useRef(false);
960
+ useEffect(() => {
961
+ if (bootstrapped.current || !ForceGraph2D || nodes.length === 0) return;
962
+ bootstrapped.current = true;
963
+ // Near-instant switch — just enough for DAG to assign positions
964
+ const timer = setTimeout(() => {
965
+ setLayoutMode("force");
966
+ }, 15);
967
+ return () => clearTimeout(timer);
968
+ }, [ForceGraph2D, nodes.length]);
969
+
970
+ // Fit to view on initial load (skip during timeline replay or when auto-fit disabled)
971
+ useEffect(() => {
972
+ if (timelineActive) return;
973
+ if (!autoFit) return;
974
+ if (graphRef.current && nodes.length > 0) {
975
+ const timer = setTimeout(() => {
976
+ graphRef.current.zoomToFit(400, 60);
977
+ }, 800);
978
+ return () => clearTimeout(timer);
979
+ }
980
+ }, [nodes.length, timelineActive, autoFit]);
981
+
982
+ // Auto zoom-to-fit when entering/exiting epic focus mode (unconditional)
983
+ const prevFocusedEpicIdRef = useRef<string | null | undefined>(undefined);
984
+ useEffect(() => {
985
+ // Skip on mount (initial render)
986
+ if (prevFocusedEpicIdRef.current === undefined) {
987
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
988
+ return;
989
+ }
990
+ prevFocusedEpicIdRef.current = focusedEpicId ?? null;
991
+ const graph = graphRef.current;
992
+ if (!graph) return;
993
+ // Small delay to let the force graph process the new node set
994
+ const timer = setTimeout(() => {
995
+ graph.zoomToFit(400, 60);
996
+ }, 100);
997
+ return () => clearTimeout(timer);
998
+ }, [focusedEpicId]);
999
+
1000
+ // Memoize graphData so the object reference stays stable across renders.
1001
+ // This prevents react-force-graph from treating it as "new data" and
1002
+ // re-heating the simulation on every hover/selection change.
1003
+ //
1004
+ // Pre-spread nodes that have no positions yet (initial load).
1005
+ // D3's default initializeNodes() places all nodes within ~44px of origin
1006
+ // using a tiny phyllotaxis spiral (initialRadius=10), which causes the
1007
+ // "squished" initial layout. We use the same golden-angle spiral but with
1008
+ // a much wider radius so nodes start well-distributed — matching what
1009
+ // happens naturally after a DAG→Force toggle.
1010
+ const graphData = useMemo(() => {
1011
+ const SPREAD = 300;
1012
+ const sqrtN = Math.sqrt(viewNodes.length) || 1;
1013
+ viewNodes.forEach((node: any, i: number) => {
1014
+ if (node.x == null && node.y == null) {
1015
+ const angle = i * Math.PI * (3 - Math.sqrt(5)); // golden angle
1016
+ const r = (SPREAD * Math.sqrt(0.5 + i)) / sqrtN;
1017
+ node.x = r * Math.cos(angle);
1018
+ node.y = r * Math.sin(angle);
1019
+ }
1020
+ });
1021
+ return { nodes: viewNodes, links: viewLinks };
1022
+ }, [viewNodes, viewLinks]);
1023
+
1024
+ // Custom node rendering - reads from refs, not props, so no dependency
1025
+ // on hoveredNode/selectedNode (which would cause useCallback to recreate
1026
+ // the function, which would cause ForceGraph to re-render).
1027
+ const paintNode = useCallback(
1028
+ (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
1029
+ const graphNode = node as GraphNode;
1030
+ const size = getNodeSize(graphNode);
1031
+ const color = getNodeColor(graphNode);
1032
+ const prefixColor = getPrefixRingColor(graphNode);
1033
+ const isSelected = selectedNodeRef.current?.id === graphNode.id;
1034
+ const isHovered = hoveredNodeRef.current?.id === graphNode.id;
1035
+ const connected = connectedNodesRef.current;
1036
+ const isConnected = connected.has(graphNode.id);
1037
+ const hasHighlight = connected.size > 0;
1038
+
1039
+ const now = Date.now();
1040
+
1041
+ // --- Spawn animation (pop-in) ---
1042
+ let spawnScale = 1;
1043
+ const spawnTime = graphNode._spawnTime;
1044
+ if (spawnTime) {
1045
+ const elapsed = now - spawnTime;
1046
+ if (elapsed < SPAWN_DURATION) {
1047
+ spawnScale = easeOutBack(elapsed / SPAWN_DURATION);
1048
+ }
1049
+ }
1050
+
1051
+ // --- Remove animation (shrink-out) ---
1052
+ let removeScale = 1;
1053
+ let removeOpacity = 1;
1054
+ const removeTime = graphNode._removeTime;
1055
+ if (removeTime) {
1056
+ const elapsed = now - removeTime;
1057
+ if (elapsed < REMOVE_DURATION) {
1058
+ const progress = elapsed / REMOVE_DURATION;
1059
+ removeScale = 1 - easeOutQuad(progress);
1060
+ removeOpacity = 1 - progress;
1061
+ } else {
1062
+ removeScale = 0;
1063
+ removeOpacity = 0;
1064
+ }
1065
+ }
1066
+
1067
+ const animScale = spawnScale * removeScale;
1068
+ if (animScale <= 0.01) return; // skip drawing invisible nodes
1069
+
1070
+ const animatedSize = size * animScale;
1071
+
1072
+ // Opacity: dim non-connected nodes when highlighting
1073
+ const opacity =
1074
+ (hasHighlight && !isConnected
1075
+ ? 0.15
1076
+ : graphNode.status === "closed"
1077
+ ? 0.5
1078
+ : 1) * removeOpacity;
1079
+
1080
+ if (opacity <= 0.01) return; // skip fully faded nodes
1081
+
1082
+ ctx.save();
1083
+ ctx.globalAlpha = opacity;
1084
+
1085
+ // Glow for connected/selected/hovered nodes
1086
+ if (isConnected && hasHighlight) {
1087
+ ctx.shadowColor = "#10b981";
1088
+ ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : 10;
1089
+ }
1090
+
1091
+ // Prefix ring (outer ring showing project)
1092
+ if (globalScale > 0.3) {
1093
+ ctx.beginPath();
1094
+ ctx.arc(node.x, node.y, animatedSize + 2, 0, Math.PI * 2);
1095
+ ctx.strokeStyle = prefixColor;
1096
+ ctx.lineWidth = 2;
1097
+ ctx.stroke();
1098
+ }
1099
+
1100
+ // Node body
1101
+ ctx.beginPath();
1102
+ ctx.arc(node.x, node.y, animatedSize, 0, Math.PI * 2);
1103
+ ctx.fillStyle = color;
1104
+ ctx.fill();
1105
+
1106
+ // Border
1107
+ ctx.strokeStyle = isSelected
1108
+ ? "#10b981"
1109
+ : isHovered
1110
+ ? "#3f3f46"
1111
+ : "#e4e4e7";
1112
+ ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1;
1113
+ ctx.stroke();
1114
+
1115
+ // Reset shadow
1116
+ ctx.shadowBlur = 0;
1117
+
1118
+ // --- Status change ripple animation ---
1119
+ const changedAt = graphNode._changedAt;
1120
+ if (changedAt) {
1121
+ const elapsed = now - changedAt;
1122
+ if (elapsed < CHANGE_DURATION) {
1123
+ const progress = elapsed / CHANGE_DURATION;
1124
+ const rippleRadius = animatedSize + 4 + progress * 20;
1125
+ const rippleOpacity = (1 - progress) * 0.6;
1126
+ const newStatusColor = STATUS_COLORS[graphNode.status] || "#a1a1aa";
1127
+
1128
+ ctx.beginPath();
1129
+ ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);
1130
+ ctx.strokeStyle = newStatusColor;
1131
+ ctx.lineWidth = 2 * (1 - progress);
1132
+ ctx.globalAlpha = rippleOpacity;
1133
+ ctx.stroke();
1134
+ ctx.globalAlpha = opacity; // reset
1135
+ }
1136
+ }
1137
+
1138
+ // --- Activity pulse ripple (continuous, on most-recently-active node) ---
1139
+ if (showPulseRef.current && pulseNodeIdRef.current === graphNode.id) {
1140
+ const RIPPLE_PERIOD = 2000; // full cycle in ms
1141
+ const RIPPLE_COUNT = 3;
1142
+ const RIPPLE_STAGGER = 500; // ms between each ring
1143
+ // Scale ripple to ~30 screen pixels regardless of zoom level
1144
+ const maxExpand = Math.max(25, 30 / globalScale);
1145
+ const MAX_RIPPLE_RADIUS = animatedSize + maxExpand;
1146
+
1147
+ for (let i = 0; i < RIPPLE_COUNT; i++) {
1148
+ const phase = ((now + i * RIPPLE_STAGGER) % RIPPLE_PERIOD) / RIPPLE_PERIOD;
1149
+ const rippleRadius = animatedSize + 2 / globalScale + phase * (MAX_RIPPLE_RADIUS - animatedSize);
1150
+ const rippleOpacity = (1 - phase) * 0.6;
1151
+
1152
+ ctx.beginPath();
1153
+ ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);
1154
+ ctx.strokeStyle = "#10b981"; // emerald-500
1155
+ ctx.lineWidth = Math.max(1.5 / globalScale, 0.5);
1156
+ ctx.globalAlpha = rippleOpacity * opacity;
1157
+ ctx.stroke();
1158
+ }
1159
+ ctx.globalAlpha = opacity; // reset
1160
+ }
1161
+
1162
+ // --- Spawn glow ---
1163
+ if (spawnTime) {
1164
+ const elapsed = now - spawnTime;
1165
+ if (elapsed < SPAWN_DURATION) {
1166
+ const glowProgress = elapsed / SPAWN_DURATION;
1167
+ const glowOpacity = (1 - glowProgress) * 0.4;
1168
+ const glowRadius = animatedSize + 6 + glowProgress * 8;
1169
+ ctx.beginPath();
1170
+ ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
1171
+ ctx.strokeStyle = "#10b981";
1172
+ ctx.lineWidth = 3 * (1 - glowProgress);
1173
+ ctx.globalAlpha = glowOpacity;
1174
+ ctx.stroke();
1175
+ ctx.globalAlpha = opacity; // reset
1176
+ }
1177
+ }
1178
+
1179
+ // Priority indicator (flame for P0/P1)
1180
+ if (graphNode.priority <= 1 && globalScale > 0.5) {
1181
+ const emojiSize = Math.min(10, Math.max(4, 12 / globalScale));
1182
+ ctx.font = `${emojiSize}px sans-serif`;
1183
+ ctx.textAlign = "center";
1184
+ ctx.textBaseline = "bottom";
1185
+ ctx.fillText(
1186
+ graphNode.priority === 0
1187
+ ? "\uD83D\uDD25\uD83D\uDD25"
1188
+ : "\uD83D\uDD25",
1189
+ node.x,
1190
+ node.y - animatedSize - 2
1191
+ );
1192
+ }
1193
+
1194
+ // Label
1195
+ if (globalScale > 0.5) {
1196
+ const fontSize = Math.min(7, Math.max(3, 10 / globalScale));
1197
+ ctx.font = `500 ${fontSize}px 'Inter', system-ui, sans-serif`;
1198
+ ctx.textAlign = "center";
1199
+ ctx.textBaseline = "top";
1200
+ ctx.fillStyle = "#3f3f46";
1201
+ ctx.globalAlpha = opacity * 0.85;
1202
+
1203
+ let label = graphNode.id;
1204
+ if (globalScale > 1.5) {
1205
+ label = truncate(graphNode.title || graphNode.id, 30);
1206
+ } else if (globalScale > 0.9) {
1207
+ label = truncate(graphNode.title || graphNode.id, 18);
1208
+ }
1209
+ ctx.fillText(label, node.x, node.y + animatedSize + 3);
1210
+
1211
+ // Collapsed child count badge (only in epics view mode)
1212
+ const collapsedCount = (graphNode as any).collapsedCount as number | undefined;
1213
+ if (collapsedCount && collapsedCount > 0) {
1214
+ const badgeFontSize = Math.min(5.5, Math.max(2.5, 8 / globalScale));
1215
+ ctx.font = `400 ${badgeFontSize}px 'Inter', system-ui, sans-serif`;
1216
+ ctx.fillStyle = "#a1a1aa"; // zinc-400
1217
+ ctx.fillText(
1218
+ `${collapsedCount} task${collapsedCount !== 1 ? "s" : ""}`,
1219
+ node.x,
1220
+ node.y + animatedSize + 3 + fontSize + 1
1221
+ );
1222
+ }
1223
+ }
1224
+
1225
+ // Comment count badge — small filled circle with number at top-right
1226
+ const commentCount = commentedNodeIdsRef.current.get(graphNode.id);
1227
+ if (commentCount && commentCount > 0 && globalScale > 0.4) {
1228
+ const badgeRadius = Math.min(6, Math.max(3.5, 8 / globalScale));
1229
+ const badgeX = node.x + animatedSize * 0.75;
1230
+ const badgeY = node.y - animatedSize * 0.75;
1231
+ const label = commentCount > 99 ? "99+" : String(commentCount);
1232
+
1233
+ ctx.save();
1234
+ ctx.globalAlpha = Math.min(opacity, 0.95);
1235
+
1236
+ // Badge circle — red like WhatsApp notification counter
1237
+ ctx.beginPath();
1238
+ ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2);
1239
+ ctx.fillStyle = "#ef4444"; // red-500
1240
+ ctx.fill();
1241
+
1242
+ // White border for contrast against any background
1243
+ ctx.strokeStyle = "#ffffff";
1244
+ ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);
1245
+ ctx.stroke();
1246
+
1247
+ // Count text
1248
+ const fontSize = Math.min(7, Math.max(3, badgeRadius * 1.3));
1249
+ ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`;
1250
+ ctx.textAlign = "center";
1251
+ ctx.textBaseline = "middle";
1252
+ ctx.fillStyle = "#ffffff";
1253
+ ctx.fillText(label, badgeX, badgeY + 0.3); // +0.3 for optical vertical centering
1254
+
1255
+ ctx.restore();
1256
+ }
1257
+
1258
+ // Claimant avatar — small circular profile picture at bottom-right
1259
+ const claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id);
1260
+ if (claimInfo) {
1261
+ // Constant screen-space size: divide by globalScale so avatar stays
1262
+ // roughly the same pixel size regardless of zoom level
1263
+ const avatarSize = Math.max(4, 10 / globalScale);
1264
+ const avatarX = node.x + animatedSize * 0.7;
1265
+ const avatarY = node.y + animatedSize * 0.7;
1266
+
1267
+ ctx.save();
1268
+ ctx.globalAlpha = 1;
1269
+
1270
+ if (claimInfo.avatar) {
1271
+ const img = getAvatarImage(claimInfo.avatar, () =>
1272
+ avatarRefreshRef.current()
1273
+ );
1274
+ if (img) {
1275
+ // Clip to circle and draw image
1276
+ ctx.save();
1277
+ ctx.beginPath();
1278
+ ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);
1279
+ ctx.clip();
1280
+ ctx.drawImage(
1281
+ img,
1282
+ avatarX - avatarSize,
1283
+ avatarY - avatarSize,
1284
+ avatarSize * 2,
1285
+ avatarSize * 2
1286
+ );
1287
+ ctx.restore();
1288
+ } else {
1289
+ drawAvatarFallback(
1290
+ ctx,
1291
+ avatarX,
1292
+ avatarY,
1293
+ avatarSize,
1294
+ claimInfo.handle,
1295
+ globalScale
1296
+ );
1297
+ }
1298
+ } else {
1299
+ drawAvatarFallback(
1300
+ ctx,
1301
+ avatarX,
1302
+ avatarY,
1303
+ avatarSize,
1304
+ claimInfo.handle,
1305
+ globalScale
1306
+ );
1307
+ }
1308
+
1309
+ // White border ring for contrast
1310
+ ctx.beginPath();
1311
+ ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2);
1312
+ ctx.strokeStyle = "#ffffff";
1313
+ ctx.lineWidth = Math.max(0.8, 1.2 / globalScale);
1314
+ ctx.stroke();
1315
+
1316
+ ctx.restore();
1317
+ }
1318
+
1319
+ ctx.restore();
1320
+ },
1321
+ [] // No dependencies - reads from refs
1322
+ );
1323
+
1324
+ // Custom link rendering — blocks links are solid with arrowheads,
1325
+ // parent-child links are dashed without arrowheads
1326
+ const paintLink = useCallback(
1327
+ (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
1328
+ const start = link.source;
1329
+ const end = link.target;
1330
+
1331
+ if (start.x === undefined || end.x === undefined) return;
1332
+
1333
+ const now = Date.now();
1334
+
1335
+ // --- Spawn animation (fade-in + thickness) ---
1336
+ let linkSpawnAlpha = 1;
1337
+ let linkSpawnWidth = 1;
1338
+ const linkSpawnTime = link._spawnTime as number | undefined;
1339
+ if (linkSpawnTime) {
1340
+ const elapsed = now - linkSpawnTime;
1341
+ if (elapsed < SPAWN_DURATION) {
1342
+ const progress = elapsed / SPAWN_DURATION;
1343
+ linkSpawnAlpha = easeOutQuad(progress);
1344
+ linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x
1345
+ }
1346
+ }
1347
+
1348
+ // --- Remove animation (fade-out) ---
1349
+ let linkRemoveAlpha = 1;
1350
+ const linkRemoveTime = link._removeTime as number | undefined;
1351
+ if (linkRemoveTime) {
1352
+ const elapsed = now - linkRemoveTime;
1353
+ if (elapsed < REMOVE_DURATION) {
1354
+ linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION);
1355
+ } else {
1356
+ return; // fully gone, skip drawing
1357
+ }
1358
+ }
1359
+
1360
+ const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha;
1361
+ if (linkAnimAlpha <= 0.01) return; // skip invisible links
1362
+
1363
+ const srcId = start.id || link.source;
1364
+ const tgtId = end.id || link.target;
1365
+ const isParentChild = link.type === "parent-child";
1366
+ const connected = connectedNodesRef.current;
1367
+ const hasHighlight = connected.size > 0;
1368
+ const isConnectedLink =
1369
+ hasHighlight && connected.has(srcId) && connected.has(tgtId);
1370
+
1371
+ // Parent-child links are more subtle
1372
+ const opacity = (isParentChild
1373
+ ? hasHighlight
1374
+ ? isConnectedLink ? 0.5 : 0.05
1375
+ : 0.2
1376
+ : hasHighlight
1377
+ ? isConnectedLink ? 0.8 : 0.08
1378
+ : 0.35) * linkAnimAlpha;
1379
+
1380
+ if (opacity <= 0.01) return; // skip fully faded links
1381
+
1382
+ ctx.save();
1383
+ ctx.globalAlpha = opacity;
1384
+
1385
+ // Color and width differ by link type
1386
+ if (isParentChild) {
1387
+ ctx.strokeStyle = isConnectedLink ? "#71717a" : "#a1a1aa"; // zinc-500 / zinc-400
1388
+ ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth;
1389
+ ctx.setLineDash([4, 3]);
1390
+ } else {
1391
+ ctx.strokeStyle = isConnectedLink ? "#10b981" : "#d4d4d8";
1392
+ ctx.lineWidth = (isConnectedLink
1393
+ ? Math.max(2, 2.5 / globalScale)
1394
+ : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth;
1395
+ }
1396
+
1397
+ // Curved link
1398
+ const dx = end.x - start.x;
1399
+ const dy = end.y - start.y;
1400
+ const dist = Math.sqrt(dx * dx + dy * dy);
1401
+ const curvature = 0.15;
1402
+ const cx = (start.x + end.x) / 2 + dy * curvature;
1403
+ const cy = (start.y + end.y) / 2 - dx * curvature;
1404
+
1405
+ ctx.beginPath();
1406
+ ctx.moveTo(start.x, start.y);
1407
+ ctx.quadraticCurveTo(cx, cy, end.x, end.y);
1408
+ ctx.stroke();
1409
+
1410
+ // Reset dash pattern
1411
+ if (isParentChild) {
1412
+ ctx.setLineDash([]);
1413
+ }
1414
+
1415
+ // Brief bright flash for new links
1416
+ if (linkSpawnTime) {
1417
+ const elapsed = now - linkSpawnTime;
1418
+ if (elapsed < 300) {
1419
+ const flashProgress = elapsed / 300;
1420
+ const flashAlpha = (1 - flashProgress) * 0.5;
1421
+ ctx.save();
1422
+ ctx.globalAlpha = flashAlpha;
1423
+ ctx.strokeStyle = "#10b981"; // emerald
1424
+ ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale;
1425
+ ctx.beginPath();
1426
+ ctx.moveTo(start.x, start.y);
1427
+ ctx.quadraticCurveTo(cx, cy, end.x, end.y);
1428
+ ctx.stroke();
1429
+ ctx.restore();
1430
+ }
1431
+ }
1432
+
1433
+ // Arrowhead — only for blocks links
1434
+ if (!isParentChild) {
1435
+ const endSize = getNodeSize(end as GraphNode);
1436
+ if (dist < endSize + 1) {
1437
+ ctx.restore();
1438
+ return;
1439
+ }
1440
+
1441
+ const arrowLen = Math.min(8, 6 / globalScale);
1442
+ const t = 1 - endSize / dist;
1443
+ const arrowX = start.x + t * dx;
1444
+ const arrowY = start.y + t * dy;
1445
+ const angle = Math.atan2(dy, dx);
1446
+
1447
+ ctx.fillStyle = isConnectedLink ? "#10b981" : "#d4d4d8";
1448
+ ctx.beginPath();
1449
+ ctx.moveTo(arrowX, arrowY);
1450
+ ctx.lineTo(
1451
+ arrowX - arrowLen * Math.cos(angle - Math.PI / 7),
1452
+ arrowY - arrowLen * Math.sin(angle - Math.PI / 7)
1453
+ );
1454
+ ctx.lineTo(
1455
+ arrowX - arrowLen * Math.cos(angle + Math.PI / 7),
1456
+ arrowY - arrowLen * Math.sin(angle + Math.PI / 7)
1457
+ );
1458
+ ctx.closePath();
1459
+ ctx.fill();
1460
+ }
1461
+
1462
+ ctx.restore();
1463
+ },
1464
+ [] // No dependencies - reads from refs
1465
+ );
1466
+
1467
+ // Semantic zoom: draw epic/cluster labels when zoomed out far.
1468
+ // Computes centroids from live node positions and draws titles.
1469
+ const paintClusterLabels = useCallback(
1470
+ (ctx: CanvasRenderingContext2D, globalScale: number) => {
1471
+ if (!showClusters) return;
1472
+
1473
+ // Only show cluster labels when zoomed out (inverse of node fade range)
1474
+ const LABEL_FADE_IN = 0.8; // starts appearing
1475
+ const LABEL_FULL = 0.4; // fully visible
1476
+ const labelAlpha = globalScale >= LABEL_FADE_IN
1477
+ ? 0
1478
+ : globalScale <= LABEL_FULL
1479
+ ? 1
1480
+ : (LABEL_FADE_IN - globalScale) / (LABEL_FADE_IN - LABEL_FULL);
1481
+
1482
+ if (labelAlpha <= 0.01) return;
1483
+
1484
+ const clusters = clustersRef.current;
1485
+ if (clusters.length === 0) return;
1486
+
1487
+ // Build a fast lookup from node ID to current LIVE position.
1488
+ // Only use viewNodes (the nodes actually in the simulation) — in epics
1489
+ // view, child nodes are collapsed into parent epics and their positions
1490
+ // are stale/frozen. Using stale positions causes centroids to drift.
1491
+ const nodeMap = new Map<string, { x: number; y: number }>();
1492
+ for (const node of viewNodes) {
1493
+ const n = node as any;
1494
+ if (n.x != null && n.y != null) {
1495
+ nodeMap.set(node.id, { x: n.x, y: n.y });
1496
+ }
1497
+ }
1498
+
1499
+ ctx.save();
1500
+
1501
+ for (const cluster of clusters) {
1502
+ // Compute centroid from member positions
1503
+ let sumX = 0;
1504
+ let sumY = 0;
1505
+ let count = 0;
1506
+ for (const id of cluster.memberIds) {
1507
+ const pos = nodeMap.get(id);
1508
+ if (pos) {
1509
+ sumX += pos.x;
1510
+ sumY += pos.y;
1511
+ count++;
1512
+ }
1513
+ }
1514
+ if (count === 0) continue;
1515
+
1516
+ const cx = sumX / count;
1517
+ const cy = sumY / count;
1518
+
1519
+ // Compute bounding radius for the subtle background circle
1520
+ let maxDist = 0;
1521
+ for (const id of cluster.memberIds) {
1522
+ const pos = nodeMap.get(id);
1523
+ if (pos) {
1524
+ const dx = pos.x - cx;
1525
+ const dy = pos.y - cy;
1526
+ const d = Math.sqrt(dx * dx + dy * dy);
1527
+ if (d > maxDist) maxDist = d;
1528
+ }
1529
+ }
1530
+ const radius = maxDist + 30; // padding around outermost node
1531
+
1532
+ // Use Catppuccin prefix color for the cluster circle (clusters always represent projects)
1533
+ const clusterColor = getCatppuccinPrefixColor(cluster.prefix);
1534
+
1535
+ // Draw subtle cluster background circle
1536
+ ctx.beginPath();
1537
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
1538
+ ctx.globalAlpha = 0.05 * labelAlpha;
1539
+ ctx.fillStyle = clusterColor;
1540
+ ctx.fill();
1541
+ ctx.globalAlpha = 0.25 * labelAlpha;
1542
+ ctx.strokeStyle = clusterColor;
1543
+ ctx.lineWidth = 1.5 / globalScale;
1544
+ ctx.setLineDash([8 / globalScale, 4 / globalScale]);
1545
+ ctx.stroke();
1546
+ ctx.setLineDash([]);
1547
+
1548
+ // Draw epic ID above the title
1549
+ const fontSize = Math.min(24, Math.max(10, 14 / globalScale));
1550
+ const idFontSize = Math.min(12, Math.max(5, 8 / globalScale));
1551
+ const lineGap = fontSize * 0.35;
1552
+
1553
+ ctx.font = `500 ${idFontSize}px 'Inter', system-ui, sans-serif`;
1554
+ ctx.textAlign = "center";
1555
+ ctx.textBaseline = "middle";
1556
+ ctx.globalAlpha = labelAlpha * 0.45;
1557
+ ctx.fillStyle = "#71717a"; // zinc-500
1558
+ ctx.fillText(cluster.parentId, cx, cy - fontSize * 0.5 - lineGap);
1559
+
1560
+ // Draw epic/cluster title at centroid
1561
+ ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`;
1562
+ ctx.globalAlpha = labelAlpha * 0.85;
1563
+ ctx.fillStyle = "#18181b"; // zinc-900
1564
+
1565
+ // Truncate long titles
1566
+ const label = cluster.title.length > 40
1567
+ ? cluster.title.slice(0, 39) + "\u2026"
1568
+ : cluster.title;
1569
+ ctx.fillText(label, cx, cy + fontSize * 0.15);
1570
+
1571
+ // Subtitle: member count
1572
+ const subFontSize = Math.min(14, Math.max(6, 9 / globalScale));
1573
+ ctx.font = `400 ${subFontSize}px 'Inter', system-ui, sans-serif`;
1574
+ ctx.globalAlpha = labelAlpha * 0.5;
1575
+ ctx.fillStyle = "#71717a"; // zinc-500
1576
+ ctx.fillText(
1577
+ `${cluster.memberIds.length} issue${cluster.memberIds.length !== 1 ? "s" : ""}`,
1578
+ cx,
1579
+ cy + fontSize * 0.15 + fontSize * 0.7
1580
+ );
1581
+ }
1582
+
1583
+ ctx.restore();
1584
+ },
1585
+ [viewNodes, nodes, showClusters] // reads clustersRef (ref), but needs viewNodes for positions
1586
+ );
1587
+
1588
+ // Node hit area
1589
+ const paintNodeArea = useCallback(
1590
+ (node: any, color: string, ctx: CanvasRenderingContext2D) => {
1591
+ const size = getNodeSize(node as GraphNode) + 5;
1592
+ ctx.fillStyle = color;
1593
+ ctx.beginPath();
1594
+ ctx.arc(node.x, node.y, size, 0, Math.PI * 2);
1595
+ ctx.fill();
1596
+ },
1597
+ []
1598
+ );
1599
+
1600
+ // Silently handle DAG cycle errors (some dependency graphs have cycles)
1601
+ const handleDagError = useCallback(() => {}, []);
1602
+
1603
+ // ── Minimap ──────────────────────────────────────────────────────────
1604
+ // Redraws the minimap canvas: node dots + FOV viewport rectangle.
1605
+ // Uses viewNodes/viewLinks so it reflects the current view mode.
1606
+ const redrawMinimap = useCallback(() => {
1607
+ const canvas = minimapCanvasRef.current;
1608
+ const fg = graphRef.current;
1609
+ if (!canvas || !fg) return;
1610
+
1611
+ const ctx = canvas.getContext("2d");
1612
+ if (!ctx) return;
1613
+
1614
+ // Get world bounds from all node positions
1615
+ let xMin = Infinity,
1616
+ xMax = -Infinity,
1617
+ yMin = Infinity,
1618
+ yMax = -Infinity;
1619
+ let hasPositions = false;
1620
+ for (const node of viewNodes) {
1621
+ const n = node as any;
1622
+ if (n.x == null || n.y == null) continue;
1623
+ hasPositions = true;
1624
+ if (n.x < xMin) xMin = n.x;
1625
+ if (n.x > xMax) xMax = n.x;
1626
+ if (n.y < yMin) yMin = n.y;
1627
+ if (n.y > yMax) yMax = n.y;
1628
+ }
1629
+ if (!hasPositions) return;
1630
+
1631
+ // Add margin so edge nodes aren't clipped
1632
+ const margin = 40;
1633
+ xMin -= margin;
1634
+ xMax += margin;
1635
+ yMin -= margin;
1636
+ yMax += margin;
1637
+
1638
+ const worldW = xMax - xMin || 1;
1639
+ const worldH = yMax - yMin || 1;
1640
+ const drawW = MINIMAP_W - MINIMAP_PAD * 2;
1641
+ const drawH = MINIMAP_H - MINIMAP_PAD * 2;
1642
+ const scale = Math.min(drawW / worldW, drawH / worldH);
1643
+ const offsetX = MINIMAP_PAD + (drawW - worldW * scale) / 2;
1644
+ const offsetY = MINIMAP_PAD + (drawH - worldH * scale) / 2;
1645
+
1646
+ // HiDPI support
1647
+ const dpr = window.devicePixelRatio || 1;
1648
+ if (canvas.width !== MINIMAP_W * dpr || canvas.height !== MINIMAP_H * dpr) {
1649
+ canvas.width = MINIMAP_W * dpr;
1650
+ canvas.height = MINIMAP_H * dpr;
1651
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1652
+ }
1653
+
1654
+ // Clear
1655
+ ctx.clearRect(0, 0, MINIMAP_W, MINIMAP_H);
1656
+
1657
+ // Background
1658
+ ctx.fillStyle = "rgba(250, 250, 250, 0.92)";
1659
+ ctx.beginPath();
1660
+ ctx.roundRect(0, 0, MINIMAP_W, MINIMAP_H, 6);
1661
+ ctx.fill();
1662
+
1663
+ // Read highlight state from refs (synced with main graph)
1664
+ const connected = connectedNodesRef.current;
1665
+ const hasHighlight = connected.size > 0;
1666
+ const activeNodeId =
1667
+ hoveredNodeRef.current?.id || selectedNodeRef.current?.id || null;
1668
+
1669
+ // Draw links
1670
+ for (const link of viewLinks) {
1671
+ const src = link.source as any;
1672
+ const tgt = link.target as any;
1673
+ if (src.x == null || tgt.x == null) continue;
1674
+ const srcId = src.id || link.source;
1675
+ const tgtId = tgt.id || link.target;
1676
+ const isConnectedLink =
1677
+ hasHighlight && connected.has(srcId) && connected.has(tgtId);
1678
+
1679
+ ctx.globalAlpha = hasHighlight
1680
+ ? isConnectedLink
1681
+ ? 0.5
1682
+ : 0.04
1683
+ : 0.1;
1684
+ ctx.strokeStyle = isConnectedLink ? "#10b981" : "#a1a1aa";
1685
+ ctx.lineWidth = isConnectedLink ? 1 : 0.5;
1686
+
1687
+ const sx = offsetX + (src.x - xMin) * scale;
1688
+ const sy = offsetY + (src.y - yMin) * scale;
1689
+ const tx = offsetX + (tgt.x - xMin) * scale;
1690
+ const ty = offsetY + (tgt.y - yMin) * scale;
1691
+ ctx.beginPath();
1692
+ ctx.moveTo(sx, sy);
1693
+ ctx.lineTo(tx, ty);
1694
+ ctx.stroke();
1695
+ }
1696
+ ctx.globalAlpha = 1;
1697
+
1698
+ // Draw nodes as tiny dots (fillRect is faster than arc)
1699
+ for (const node of viewNodes) {
1700
+ const n = node as any;
1701
+ if (n.x == null || n.y == null) continue;
1702
+ const mx = offsetX + (n.x - xMin) * scale;
1703
+ const my = offsetY + (n.y - yMin) * scale;
1704
+ const isActive = node.id === activeNodeId;
1705
+ const isConnected = connected.has(node.id);
1706
+
1707
+ // Opacity: dim non-connected when highlighting, just like main graph
1708
+ if (hasHighlight && !isConnected) {
1709
+ ctx.globalAlpha = 0.1;
1710
+ } else if (node.status === "closed") {
1711
+ ctx.globalAlpha = 0.35;
1712
+ } else {
1713
+ ctx.globalAlpha = 0.85;
1714
+ }
1715
+
1716
+ ctx.fillStyle = getNodeColor(node);
1717
+ // Connected/active nodes get a bigger dot + glow
1718
+ let dotSize = node.issueType === "epic" ? 3 : 2;
1719
+ if (isActive) {
1720
+ dotSize = 5;
1721
+ } else if (hasHighlight && isConnected) {
1722
+ dotSize = 4;
1723
+ }
1724
+
1725
+ // Glow ring for the active node
1726
+ if (isActive) {
1727
+ ctx.globalAlpha = 0.4;
1728
+ ctx.fillStyle = "#10b981";
1729
+ ctx.beginPath();
1730
+ ctx.arc(mx, my, dotSize + 2, 0, Math.PI * 2);
1731
+ ctx.fill();
1732
+ ctx.globalAlpha = 1;
1733
+ ctx.fillStyle = getNodeColor(node);
1734
+ }
1735
+
1736
+ ctx.fillRect(mx - dotSize / 2, my - dotSize / 2, dotSize, dotSize);
1737
+ }
1738
+ ctx.globalAlpha = 1;
1739
+
1740
+ // Draw claimed avatars on minimap
1741
+ const claimedMap = claimedNodeAvatarsRef.current;
1742
+ if (claimedMap.size > 0) {
1743
+ for (const node of viewNodes) {
1744
+ const n = node as any;
1745
+ if (n.x == null || n.y == null) continue;
1746
+ const claim = claimedMap.get(node.id);
1747
+ if (!claim) continue;
1748
+
1749
+ const mx = offsetX + (n.x - xMin) * scale;
1750
+ const my = offsetY + (n.y - yMin) * scale;
1751
+ const r = 5; // fixed pixel radius on minimap
1752
+
1753
+ ctx.save();
1754
+ ctx.globalAlpha = 1;
1755
+
1756
+ if (claim.avatar) {
1757
+ const img = getAvatarImage(claim.avatar, () =>
1758
+ avatarRefreshRef.current()
1759
+ );
1760
+ if (img) {
1761
+ ctx.save();
1762
+ ctx.beginPath();
1763
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1764
+ ctx.clip();
1765
+ ctx.drawImage(img, mx - r, my - r, r * 2, r * 2);
1766
+ ctx.restore();
1767
+ } else {
1768
+ // Fallback circle
1769
+ ctx.beginPath();
1770
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1771
+ ctx.fillStyle = "#d4d4d8";
1772
+ ctx.fill();
1773
+ }
1774
+ } else {
1775
+ ctx.beginPath();
1776
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1777
+ ctx.fillStyle = "#d4d4d8";
1778
+ ctx.fill();
1779
+ }
1780
+
1781
+ // White border
1782
+ ctx.beginPath();
1783
+ ctx.arc(mx, my, r, 0, Math.PI * 2);
1784
+ ctx.strokeStyle = "#ffffff";
1785
+ ctx.lineWidth = 1;
1786
+ ctx.stroke();
1787
+
1788
+ ctx.restore();
1789
+ }
1790
+ }
1791
+
1792
+ // Draw FOV rectangle
1793
+ try {
1794
+ const tl = fg.screen2GraphCoords(0, 0);
1795
+ const br = fg.screen2GraphCoords(dimensions.width, dimensions.height);
1796
+ const rx = offsetX + (tl.x - xMin) * scale;
1797
+ const ry = offsetY + (tl.y - yMin) * scale;
1798
+ const rw = (br.x - tl.x) * scale;
1799
+ const rh = (br.y - tl.y) * scale;
1800
+
1801
+ // Clamp to minimap bounds
1802
+ const clampX = Math.max(0, rx);
1803
+ const clampY = Math.max(0, ry);
1804
+ const clampW = Math.min(MINIMAP_W - clampX, rw - (clampX - rx));
1805
+ const clampH = Math.min(MINIMAP_H - clampY, rh - (clampY - ry));
1806
+
1807
+ if (clampW > 0 && clampH > 0) {
1808
+ // Fill
1809
+ ctx.fillStyle = "rgba(16, 185, 129, 0.06)";
1810
+ ctx.fillRect(clampX, clampY, clampW, clampH);
1811
+ // Border
1812
+ ctx.strokeStyle = "rgba(16, 185, 129, 0.5)";
1813
+ ctx.lineWidth = 1.5;
1814
+ ctx.strokeRect(clampX, clampY, clampW, clampH);
1815
+ }
1816
+ } catch {
1817
+ // screen2GraphCoords can fail before graph is fully initialized
1818
+ }
1819
+ }, [viewNodes, viewLinks, dimensions, MINIMAP_W, MINIMAP_H, MINIMAP_PAD]);
1820
+
1821
+ // Keep ref in sync so effects declared before redrawMinimap can call it
1822
+ redrawMinimapRef.current = redrawMinimap;
1823
+
1824
+ // Trigger minimap redraw on every zoom/pan event
1825
+ const handleZoom = useCallback(() => {
1826
+ // Debounce with rAF to avoid redundant redraws
1827
+ cancelAnimationFrame(minimapRafRef.current);
1828
+ minimapRafRef.current = requestAnimationFrame(() => {
1829
+ redrawMinimap();
1830
+ });
1831
+ }, [redrawMinimap]);
1832
+
1833
+ // Redraw minimap periodically during simulation (nodes move)
1834
+ useEffect(() => {
1835
+ if (!ForceGraph2D || nodes.length === 0) return;
1836
+ const interval = setInterval(() => {
1837
+ redrawMinimap();
1838
+ }, 200);
1839
+ return () => clearInterval(interval);
1840
+ }, [ForceGraph2D, nodes.length, redrawMinimap]);
1841
+
1842
+ // Drive continuous canvas redraws during active animations
1843
+ useEffect(() => {
1844
+ let rafId: number;
1845
+ let active = true;
1846
+
1847
+ function tick() {
1848
+ if (!active) return;
1849
+ const now = Date.now();
1850
+ const hasActiveAnimations =
1851
+ viewNodes.some((n: GraphNode) => {
1852
+ if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true;
1853
+ if (n._removeTime && now - n._removeTime < REMOVE_DURATION)
1854
+ return true;
1855
+ if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true;
1856
+ return false;
1857
+ }) ||
1858
+ viewLinks.some((l: GraphLink) => {
1859
+ if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true;
1860
+ if (l._removeTime && now - l._removeTime < REMOVE_DURATION)
1861
+ return true;
1862
+ return false;
1863
+ });
1864
+
1865
+ if (hasActiveAnimations) {
1866
+ refreshGraph(graphRef);
1867
+ }
1868
+ rafId = requestAnimationFrame(tick);
1869
+ }
1870
+
1871
+ tick();
1872
+ return () => {
1873
+ active = false;
1874
+ cancelAnimationFrame(rafId);
1875
+ };
1876
+ }, [viewNodes, viewLinks]);
1877
+
1878
+ // Click on minimap to navigate the main graph
1879
+ const handleMinimapClick = useCallback(
1880
+ (e: React.MouseEvent<HTMLCanvasElement>) => {
1881
+ const fg = graphRef.current;
1882
+ if (!fg) return;
1883
+
1884
+ const rect = e.currentTarget.getBoundingClientRect();
1885
+ const mx = e.clientX - rect.left;
1886
+ const my = e.clientY - rect.top;
1887
+
1888
+ // Recompute world bounds (same logic as redrawMinimap)
1889
+ let xMin = Infinity,
1890
+ xMax = -Infinity,
1891
+ yMin = Infinity,
1892
+ yMax = -Infinity;
1893
+ for (const node of viewNodes) {
1894
+ const n = node as any;
1895
+ if (n.x == null || n.y == null) continue;
1896
+ if (n.x < xMin) xMin = n.x;
1897
+ if (n.x > xMax) xMax = n.x;
1898
+ if (n.y < yMin) yMin = n.y;
1899
+ if (n.y > yMax) yMax = n.y;
1900
+ }
1901
+ const margin = 40;
1902
+ xMin -= margin;
1903
+ xMax += margin;
1904
+ yMin -= margin;
1905
+ yMax += margin;
1906
+ const worldW = xMax - xMin || 1;
1907
+ const worldH = yMax - yMin || 1;
1908
+ const drawW = MINIMAP_W - MINIMAP_PAD * 2;
1909
+ const drawH = MINIMAP_H - MINIMAP_PAD * 2;
1910
+ const scale = Math.min(drawW / worldW, drawH / worldH);
1911
+ const offsetX = MINIMAP_PAD + (drawW - worldW * scale) / 2;
1912
+ const offsetY = MINIMAP_PAD + (drawH - worldH * scale) / 2;
1913
+
1914
+ // Map minimap pixel → graph coordinate
1915
+ const graphX = xMin + (mx - offsetX) / scale;
1916
+ const graphY = yMin + (my - offsetY) / scale;
1917
+
1918
+ fg.centerAt(graphX, graphY, 300);
1919
+ },
1920
+ [viewNodes, MINIMAP_W, MINIMAP_H, MINIMAP_PAD]
1921
+ );
1922
+
1923
+ // Wrapped node click handler with double-tap detection for mobile
1924
+ const handleNodeClickWithDoubleTap = useCallback(
1925
+ (node: any) => {
1926
+ const graphNode = node as GraphNode;
1927
+
1928
+ if (!isMobile) {
1929
+ // Desktop: immediate click, no delay
1930
+ onNodeClick(graphNode);
1931
+ return;
1932
+ }
1933
+
1934
+ const now = Date.now();
1935
+ const last = lastTapRef.current;
1936
+
1937
+ if (last && last.nodeId === graphNode.id && now - last.time < 300) {
1938
+ // Double-tap detected — cancel pending single-tap
1939
+ if (tapTimeoutRef.current) {
1940
+ clearTimeout(tapTimeoutRef.current);
1941
+ tapTimeoutRef.current = null;
1942
+ }
1943
+ lastTapRef.current = null;
1944
+ onNodeDoubleTap?.(graphNode, window.innerWidth / 2, window.innerHeight / 2);
1945
+ } else {
1946
+ // First tap — delay single-tap to wait for potential second tap
1947
+ lastTapRef.current = { nodeId: graphNode.id, time: now };
1948
+ if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current);
1949
+ tapTimeoutRef.current = setTimeout(() => {
1950
+ tapTimeoutRef.current = null;
1951
+ lastTapRef.current = null;
1952
+ onNodeClick(graphNode);
1953
+ }, 300);
1954
+ }
1955
+ },
1956
+ [isMobile, onNodeClick, onNodeDoubleTap]
1957
+ );
1958
+
1959
+ return (
1960
+ <div ref={containerRef} className="w-full h-full relative" data-tutorial="graph">
1961
+ {/* Top-left controls */}
1962
+ <div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2">
1963
+ {/* Focus mode banner */}
1964
+ {focusedEpicId && onExitFocusedEpic && (
1965
+ <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-emerald-50/90 backdrop-blur-sm rounded-lg border border-emerald-200 shadow-sm text-emerald-700">
1966
+ <svg className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1967
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M16.5 20.25H18A2.25 2.25 0 0020.25 18v-1.5M7.5 20.25H6A2.25 2.25 0 013.75 18v-1.5" />
1968
+ </svg>
1969
+ <span className="truncate max-w-[180px]">
1970
+ Focused: <span className="font-semibold">{nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}</span>
1971
+ </span>
1972
+ <button
1973
+ onClick={onExitFocusedEpic}
1974
+ className="ml-auto p-0.5 rounded hover:bg-emerald-200/50 transition-colors flex-shrink-0"
1975
+ title="Show full graph"
1976
+ >
1977
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
1978
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1979
+ </svg>
1980
+ </button>
1981
+ </div>
1982
+ )}
1983
+
1984
+ {/* Row 1: Layout shape controls */}
1985
+ <div className="flex items-start gap-1.5 sm:gap-2">
1986
+ {/* Layout mode toggle */}
1987
+ <div className="flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden" data-tutorial="layouts">
1988
+ <button
1989
+ onClick={() => setLayoutMode("force")}
1990
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
1991
+ layoutMode === "force"
1992
+ ? "bg-emerald-500 text-white"
1993
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
1994
+ }`}
1995
+ >
1996
+ <span className="flex items-center gap-1.5">
1997
+ <svg
1998
+ className="w-3.5 h-3.5"
1999
+ viewBox="0 0 16 16"
2000
+ fill="none"
2001
+ stroke="currentColor"
2002
+ strokeWidth="1.5"
2003
+ >
2004
+ {/* Spring/force icon: scattered dots with connections */}
2005
+ <circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
2006
+ <circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
2007
+ <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
2008
+ <circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
2009
+ <circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
2010
+ <line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
2011
+ <line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
2012
+ <line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
2013
+ <line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
2014
+ </svg>
2015
+ <span className="hidden sm:inline">Force</span>
2016
+ </span>
2017
+ </button>
2018
+ <div className="w-px bg-zinc-200" />
2019
+ <button
2020
+ onClick={() => setLayoutMode("dag")}
2021
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2022
+ layoutMode === "dag"
2023
+ ? "bg-emerald-500 text-white"
2024
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2025
+ }`}
2026
+ >
2027
+ <span className="flex items-center gap-1.5">
2028
+ <svg
2029
+ className="w-3.5 h-3.5"
2030
+ viewBox="0 0 16 16"
2031
+ fill="none"
2032
+ stroke="currentColor"
2033
+ strokeWidth="1.5"
2034
+ >
2035
+ {/* Tree/DAG icon: top-down hierarchy */}
2036
+ <circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
2037
+ <circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
2038
+ <circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
2039
+ <circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
2040
+ <circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
2041
+ <circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
2042
+ <line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
2043
+ <line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
2044
+ <line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
2045
+ <line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
2046
+ <line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
2047
+ </svg>
2048
+ <span className="hidden sm:inline">DAG</span>
2049
+ </span>
2050
+ </button>
2051
+ <div className="w-px bg-zinc-200" />
2052
+ <button
2053
+ onClick={() => setLayoutMode("radial")}
2054
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2055
+ layoutMode === "radial"
2056
+ ? "bg-emerald-500 text-white"
2057
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2058
+ }`}
2059
+ >
2060
+ <span className="flex items-center gap-1.5">
2061
+ <svg
2062
+ className="w-3.5 h-3.5"
2063
+ viewBox="0 0 16 16"
2064
+ fill="none"
2065
+ stroke="currentColor"
2066
+ strokeWidth="1.5"
2067
+ >
2068
+ {/* Radial icon: concentric rings with center dot */}
2069
+ <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
2070
+ <circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
2071
+ <circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
2072
+ </svg>
2073
+ <span className="hidden sm:inline">Radial</span>
2074
+ </span>
2075
+ </button>
2076
+ <div className="w-px bg-zinc-200" />
2077
+ <button
2078
+ onClick={() => setLayoutMode("cluster")}
2079
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2080
+ layoutMode === "cluster"
2081
+ ? "bg-emerald-500 text-white"
2082
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2083
+ }`}
2084
+ >
2085
+ <span className="flex items-center gap-1.5">
2086
+ <svg
2087
+ className="w-3.5 h-3.5"
2088
+ viewBox="0 0 16 16"
2089
+ fill="none"
2090
+ stroke="currentColor"
2091
+ strokeWidth="1.5"
2092
+ >
2093
+ {/* Cluster icon: two groups of dots */}
2094
+ <circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
2095
+ <circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
2096
+ <circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
2097
+ <circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
2098
+ <circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
2099
+ <circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
2100
+ </svg>
2101
+ <span className="hidden sm:inline">Cluster</span>
2102
+ </span>
2103
+ </button>
2104
+ <div className="w-px bg-zinc-200" />
2105
+ <button
2106
+ onClick={() => setLayoutMode("spread")}
2107
+ className={`px-3 py-1.5 text-xs font-medium transition-colors ${
2108
+ layoutMode === "spread"
2109
+ ? "bg-emerald-500 text-white"
2110
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
2111
+ }`}
2112
+ >
2113
+ <span className="flex items-center gap-1.5">
2114
+ <svg
2115
+ className="w-3.5 h-3.5"
2116
+ viewBox="0 0 16 16"
2117
+ fill="none"
2118
+ stroke="currentColor"
2119
+ strokeWidth="1.5"
2120
+ >
2121
+ {/* Spread icon: dots spread far apart */}
2122
+ <circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
2123
+ <circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
2124
+ <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
2125
+ <circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
2126
+ <circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
2127
+ </svg>
2128
+ <span className="hidden sm:inline">Spread</span>
2129
+ </span>
2130
+ </button>
2131
+ </div>
2132
+ </div>
2133
+
2134
+ {/* Row 2: View toggles */}
2135
+ <div className="flex items-start gap-1.5 sm:gap-2" data-tutorial="view-controls">
2136
+ {/* Collapse / Expand all toggle */}
2137
+ {(onCollapseAll || onExpandAll) && (
2138
+ <button
2139
+ onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
2140
+ 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"
2141
+ data-tutorial="btn-collapse"
2142
+ >
2143
+ {collapsedEpicIds && collapsedEpicIds.size > 0 ? (
2144
+ <>
2145
+ <svg
2146
+ className="w-3.5 h-3.5"
2147
+ viewBox="0 0 24 24"
2148
+ fill="none"
2149
+ strokeWidth={1.5}
2150
+ stroke="currentColor"
2151
+ >
2152
+ <path
2153
+ strokeLinecap="round"
2154
+ strokeLinejoin="round"
2155
+ 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"
2156
+ />
2157
+ </svg>
2158
+ <span className="hidden sm:inline">Expand all</span>
2159
+ </>
2160
+ ) : (
2161
+ <>
2162
+ <svg
2163
+ className="w-3.5 h-3.5"
2164
+ viewBox="0 0 24 24"
2165
+ fill="none"
2166
+ strokeWidth={1.5}
2167
+ stroke="currentColor"
2168
+ >
2169
+ <path
2170
+ strokeLinecap="round"
2171
+ strokeLinejoin="round"
2172
+ 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"
2173
+ />
2174
+ </svg>
2175
+ <span className="hidden sm:inline">Collapse all</span>
2176
+ </>
2177
+ )}
2178
+ </button>
2179
+ )}
2180
+
2181
+ {/* Show/hide cluster labels toggle */}
2182
+ <button
2183
+ onClick={() => setShowClusters((v) => !v)}
2184
+ 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 ${
2185
+ showClusters
2186
+ ? "bg-emerald-500 text-white border-emerald-500"
2187
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2188
+ }`}
2189
+ title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
2190
+ data-tutorial="btn-clusters"
2191
+ >
2192
+ <svg
2193
+ className="w-3.5 h-3.5"
2194
+ viewBox="0 0 16 16"
2195
+ fill="none"
2196
+ stroke="currentColor"
2197
+ strokeWidth="1.5"
2198
+ >
2199
+ {/* Dashed circle with label lines — cluster overlay icon */}
2200
+ <circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
2201
+ <line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
2202
+ <line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
2203
+ </svg>
2204
+ <span className="hidden sm:inline">Clusters</span>
2205
+ </button>
2206
+
2207
+ {/* Auto-fit: lock/unlock automatic camera reframing */}
2208
+ <button
2209
+ onClick={() => onAutoFitToggle?.()}
2210
+ 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 ${
2211
+ autoFit
2212
+ ? "bg-emerald-500 text-white border-emerald-500"
2213
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2214
+ }`}
2215
+ title={autoFit ? "Auto-fit enabled: camera adjusts after updates" : "Auto-fit disabled: camera stays fixed"}
2216
+ data-tutorial="btn-autofit"
2217
+ >
2218
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
2219
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75H6A2.25 2.25 0 003.75 6v1.5M16.5 3.75H18A2.25 2.25 0 0120.25 6v1.5M20.25 16.5V18A2.25 2.25 0 0118 20.25h-1.5M3.75 16.5V18A2.25 2.25 0 006 20.25h1.5" />
2220
+ <circle cx="12" cy="12" r="3" />
2221
+ </svg>
2222
+ <span className="hidden sm:inline">Auto-fit</span>
2223
+ </button>
2224
+
2225
+ {/* Pulse: highlight most recently active node */}
2226
+ <button
2227
+ onClick={() => onShowPulseToggle?.()}
2228
+ 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 ${
2229
+ showPulse
2230
+ ? "bg-emerald-500 text-white border-emerald-500"
2231
+ : "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
2232
+ }`}
2233
+ title={showPulse ? "Pulse enabled: ripple highlights most recent activity" : "Pulse disabled: no activity highlight"}
2234
+ data-tutorial="btn-pulse"
2235
+ >
2236
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
2237
+ <circle cx="12" cy="12" r="3" />
2238
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.07 4.93A10 10 0 014.93 19.07M4.93 4.93a10 10 0 0114.14 14.14" strokeOpacity="0.5" />
2239
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.24 7.76a6 6 0 01.01 8.49M7.76 7.76a6 6 0 000 8.49" strokeOpacity="0.7" />
2240
+ </svg>
2241
+ <span className="hidden sm:inline">Pulse</span>
2242
+ </button>
2243
+ </div>
2244
+ </div>
2245
+
2246
+ {/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */}
2247
+ {!timelineActive && (
2248
+ <div
2249
+ className="absolute bottom-2 sm:bottom-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-2 sm:px-3 py-1.5 sm:py-2 text-xs text-zinc-400 transition-[right] duration-300 ease-out"
2250
+ data-tutorial="legend"
2251
+ style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem", maxWidth: 320 }}
2252
+ >
2253
+ {stats && (
2254
+ <div className="text-zinc-500 mb-1.5" data-tutorial="legend-stats">
2255
+ <strong className="text-zinc-700">{stats.total}</strong> issues
2256
+ {" · "}
2257
+ <strong className="text-zinc-700">{stats.edges}</strong> deps
2258
+ {" · "}
2259
+ <strong className="text-emerald-600">{stats.prefixes.length}</strong>
2260
+ {stats.prefixes.length === 1 ? " project" : " projects"}
2261
+ </div>
2262
+ )}
2263
+ {/* Color mode segmented control */}
2264
+ <div className="hidden sm:flex bg-zinc-100 rounded-md overflow-hidden mb-1.5" data-tutorial="legend-color-mode">
2265
+ {(["status", "priority", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
2266
+ <button
2267
+ key={mode}
2268
+ onClick={() => onColorModeChange?.(mode)}
2269
+ className={`flex-1 px-2 py-1 text-[10px] font-medium transition-colors ${
2270
+ colorMode === mode
2271
+ ? "bg-emerald-500 text-white"
2272
+ : "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-200/60"
2273
+ }`}
2274
+ >
2275
+ {COLOR_MODE_LABELS[mode]}
2276
+ </button>
2277
+ ))}
2278
+ </div>
2279
+ {/* Dynamic legend: status/priority dots or person/prefix dots */}
2280
+ <div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5" data-tutorial="legend-items">
2281
+ {colorMode === "status" ? (
2282
+ <>
2283
+ {["open", "in_progress", "blocked", "deferred", "closed"].map((status) => (
2284
+ <span key={status} className="flex items-center gap-1">
2285
+ <span
2286
+ className="w-2 h-2 rounded-full"
2287
+ style={{ backgroundColor: STATUS_COLORS[status] }}
2288
+ />
2289
+ <span className="text-zinc-500">{STATUS_LABELS[status]}</span>
2290
+ </span>
2291
+ ))}
2292
+ </>
2293
+ ) : colorMode === "priority" ? (
2294
+ <>
2295
+ {[0, 1, 2, 3, 4].map((p) => (
2296
+ <span key={p} className="flex items-center gap-1">
2297
+ <span
2298
+ className="w-2 h-2 rounded-full"
2299
+ style={{ backgroundColor: PRIORITY_COLORS[p] }}
2300
+ />
2301
+ <span className="text-zinc-500">{PRIORITY_LABELS[p]}</span>
2302
+ </span>
2303
+ ))}
2304
+ </>
2305
+ ) : (
2306
+ <>
2307
+ {legendItems.map(({ label, color }) => (
2308
+ <span key={label} className="flex items-center gap-1">
2309
+ <span
2310
+ className="w-2 h-2 rounded-full flex-shrink-0"
2311
+ style={{ backgroundColor: color }}
2312
+ />
2313
+ <span className="text-zinc-500 truncate max-w-[80px]">{label}</span>
2314
+ </span>
2315
+ ))}
2316
+ </>
2317
+ )}
2318
+ </div>
2319
+ <div className="hidden sm:flex flex-col gap-0.5 text-zinc-400">
2320
+ <span>
2321
+ Size = importance · Ring = project
2322
+ {colorMode !== "status" && ` · Fill = ${COLOR_MODE_LABELS[colorMode].toLowerCase()}`}
2323
+ </span>
2324
+ </div>
2325
+ <span className="sm:hidden">Tap a node for details</span>
2326
+ </div>
2327
+ )}
2328
+
2329
+ {/* Minimap — bottom-left, hidden on mobile, resizable */}
2330
+ <div
2331
+ className="hidden sm:block absolute bottom-4 left-4 z-10"
2332
+ data-tutorial="minimap"
2333
+ style={{ width: MINIMAP_W, height: MINIMAP_H }}
2334
+ >
2335
+ <canvas
2336
+ ref={minimapCanvasRef}
2337
+ width={MINIMAP_W}
2338
+ height={MINIMAP_H}
2339
+ onClick={handleMinimapClick}
2340
+ className="rounded-lg border border-zinc-200 shadow-sm cursor-crosshair"
2341
+ style={{ width: MINIMAP_W, height: MINIMAP_H }}
2342
+ />
2343
+ {/* Resize handle — top edge */}
2344
+ <div
2345
+ className="absolute top-0 left-2 right-2 h-1.5 cursor-n-resize hover:bg-zinc-300/40 rounded-t-lg transition-colors"
2346
+ onMouseDown={(e) => {
2347
+ e.preventDefault();
2348
+ e.stopPropagation();
2349
+ minimapDragRef.current = {
2350
+ edge: "top",
2351
+ startX: e.clientX,
2352
+ startY: e.clientY,
2353
+ startW: MINIMAP_W,
2354
+ startH: MINIMAP_H,
2355
+ };
2356
+ const onMove = (ev: MouseEvent) => {
2357
+ if (!minimapDragRef.current) return;
2358
+ const dy = minimapDragRef.current.startY - ev.clientY;
2359
+ const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy));
2360
+ setMinimapSize((prev) => ({ ...prev, h: newH }));
2361
+ };
2362
+ const onUp = () => {
2363
+ minimapDragRef.current = null;
2364
+ window.removeEventListener("mousemove", onMove);
2365
+ window.removeEventListener("mouseup", onUp);
2366
+ };
2367
+ window.addEventListener("mousemove", onMove);
2368
+ window.addEventListener("mouseup", onUp);
2369
+ }}
2370
+ />
2371
+ {/* Resize handle — right edge */}
2372
+ <div
2373
+ className="absolute top-2 right-0 bottom-2 w-1.5 cursor-e-resize hover:bg-zinc-300/40 rounded-r-lg transition-colors"
2374
+ onMouseDown={(e) => {
2375
+ e.preventDefault();
2376
+ e.stopPropagation();
2377
+ minimapDragRef.current = {
2378
+ edge: "right",
2379
+ startX: e.clientX,
2380
+ startY: e.clientY,
2381
+ startW: MINIMAP_W,
2382
+ startH: MINIMAP_H,
2383
+ };
2384
+ const onMove = (ev: MouseEvent) => {
2385
+ if (!minimapDragRef.current) return;
2386
+ const dx = ev.clientX - minimapDragRef.current.startX;
2387
+ const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx));
2388
+ setMinimapSize((prev) => ({ ...prev, w: newW }));
2389
+ };
2390
+ const onUp = () => {
2391
+ minimapDragRef.current = null;
2392
+ window.removeEventListener("mousemove", onMove);
2393
+ window.removeEventListener("mouseup", onUp);
2394
+ };
2395
+ window.addEventListener("mousemove", onMove);
2396
+ window.addEventListener("mouseup", onUp);
2397
+ }}
2398
+ />
2399
+ {/* Resize handle — top-right corner */}
2400
+ <div
2401
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize hover:bg-zinc-300/40 rounded-tr-lg transition-colors"
2402
+ onMouseDown={(e) => {
2403
+ e.preventDefault();
2404
+ e.stopPropagation();
2405
+ minimapDragRef.current = {
2406
+ edge: "top-right",
2407
+ startX: e.clientX,
2408
+ startY: e.clientY,
2409
+ startW: MINIMAP_W,
2410
+ startH: MINIMAP_H,
2411
+ };
2412
+ const onMove = (ev: MouseEvent) => {
2413
+ if (!minimapDragRef.current) return;
2414
+ const dx = ev.clientX - minimapDragRef.current.startX;
2415
+ const dy = minimapDragRef.current.startY - ev.clientY;
2416
+ const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx));
2417
+ const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy));
2418
+ setMinimapSize({ w: newW, h: newH });
2419
+ };
2420
+ const onUp = () => {
2421
+ minimapDragRef.current = null;
2422
+ window.removeEventListener("mousemove", onMove);
2423
+ window.removeEventListener("mouseup", onUp);
2424
+ };
2425
+ window.addEventListener("mousemove", onMove);
2426
+ window.addEventListener("mouseup", onUp);
2427
+ }}
2428
+ />
2429
+ </div>
2430
+
2431
+ {ForceGraph2D ? (
2432
+ <ForceGraph2D
2433
+ ref={graphRef}
2434
+ graphData={graphData}
2435
+ width={dimensions.width}
2436
+ height={dimensions.height}
2437
+ // Node rendering
2438
+ nodeCanvasObject={paintNode}
2439
+ nodeCanvasObjectMode={() => "replace"}
2440
+ nodePointerAreaPaint={paintNodeArea}
2441
+ // Semantic zoom: draw cluster labels after nodes/links when zoomed out
2442
+ onRenderFramePost={paintClusterLabels}
2443
+ // Link rendering
2444
+ linkCanvasObject={paintLink}
2445
+ linkCanvasObjectMode={() => "replace"}
2446
+ // Flow particles along blocks edges only (not parent-child)
2447
+ linkDirectionalParticles={(link: any) => link.type === "parent-child" ? 0 : 2}
2448
+ linkDirectionalParticleSpeed={0.004}
2449
+ linkDirectionalParticleWidth={2.5}
2450
+ linkDirectionalParticleColor={() => "#10b981"}
2451
+ // DAG mode: "td" for top-down topological layout, undefined for force
2452
+ dagMode={layoutMode === "dag" ? "td" : undefined}
2453
+ dagLevelDistance={150}
2454
+ onDagError={handleDagError}
2455
+ // Forces
2456
+ d3AlphaDecay={0.02}
2457
+ d3VelocityDecay={0.3}
2458
+ cooldownTicks={300}
2459
+ warmupTicks={50}
2460
+ // Interactions
2461
+ onNodeClick={handleNodeClickWithDoubleTap}
2462
+ onNodeHover={(node: any) =>
2463
+ onNodeHover(node ? (node as GraphNode) : null, lastMouseRef.current.x, lastMouseRef.current.y)
2464
+ }
2465
+ onNodeRightClick={(node: any, event: MouseEvent) => {
2466
+ event.preventDefault();
2467
+ onNodeRightClick?.(node as GraphNode, event);
2468
+ }}
2469
+ onBackgroundClick={onBackgroundClick}
2470
+ // Minimap: update FOV on every zoom/pan
2471
+ onZoom={handleZoom}
2472
+ // Background
2473
+ backgroundColor="transparent"
2474
+ // Disable auto-pause when pulse is active so canvas redraws every frame
2475
+ autoPauseRedraw={!(showPulse && pulseNodeId)}
2476
+ />
2477
+ ) : (
2478
+ <div className="flex items-center justify-center h-full">
2479
+ <div className="text-zinc-400 text-sm animate-pulse-soft">
2480
+ Loading graph engine...
2481
+ </div>
2482
+ </div>
2483
+ )}
2484
+ </div>
2485
+ );
2486
+ });
2487
+
2488
+ export default BeadsGraph;
2489
+
2490
+ function truncate(str: string, len: number): string {
2491
+ if (str.length <= len) return str;
2492
+ return str.slice(0, len - 1) + "\u2026";
2493
+ }