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.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- 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
|
+
}
|