beads-map 0.3.1 → 0.3.4
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 -1
- package/.next/app-build-manifest.json +2 -2
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/next-minimal-server.js.nft.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/beads.body +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +1 -1
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +1 -0
- package/.next/static/css/ade5301262971664.css +3 -0
- package/README.md +21 -11
- package/app/page.tsx +150 -7
- package/components/BeadTooltip.tsx +26 -2
- package/components/BeadsGraph.tsx +433 -243
- package/components/ContextMenu.tsx +51 -5
- package/components/DescriptionModal.tsx +48 -18
- package/components/HelpPanel.tsx +336 -0
- package/components/NodeDetail.tsx +33 -8
- package/components/TutorialOverlay.tsx +187 -0
- package/lib/types.ts +2 -1
- package/lib/utils.ts +23 -0
- package/package.json +1 -1
- package/.next/static/chunks/app/page-a0493d6741516b53.js +0 -1
- package/.next/static/css/4fded26534cb91e3.css +0 -3
- /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_buildManifest.js +0 -0
- /package/.next/static/{_OvcD8YYgVPHv6Tomg-pB → JmL0suxsggbSwPxWcmUFV}/_ssgManifest.js +0 -0
|
@@ -12,7 +12,7 @@ import React, {
|
|
|
12
12
|
import { forceCollide, forceRadial, forceX, forceY } from "d3-force";
|
|
13
13
|
import type { GraphNode, GraphLink, ColorMode } from "@/lib/types";
|
|
14
14
|
import {
|
|
15
|
-
STATUS_COLORS, STATUS_LABELS,
|
|
15
|
+
STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS, PRIORITY_LABELS,
|
|
16
16
|
COLOR_MODE_LABELS, getPersonColor, getCatppuccinPrefixColor, getPrefixLabel,
|
|
17
17
|
} from "@/lib/types";
|
|
18
18
|
|
|
@@ -52,6 +52,20 @@ interface BeadsGraphProps {
|
|
|
52
52
|
colorMode?: ColorMode;
|
|
53
53
|
/** Callback to change color mode (from legend selector) */
|
|
54
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;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
// Node size calculation
|
|
@@ -75,6 +89,8 @@ let _currentColorMode: ColorMode = "status";
|
|
|
75
89
|
// Get color based on current color mode
|
|
76
90
|
function getNodeColor(node: GraphNode): string {
|
|
77
91
|
switch (_currentColorMode) {
|
|
92
|
+
case "priority":
|
|
93
|
+
return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2];
|
|
78
94
|
case "owner":
|
|
79
95
|
return getPersonColor(node.createdBy);
|
|
80
96
|
case "assignee":
|
|
@@ -237,6 +253,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
237
253
|
onExpandAll,
|
|
238
254
|
colorMode = "status",
|
|
239
255
|
onColorModeChange,
|
|
256
|
+
autoFit = true,
|
|
257
|
+
onAutoFitToggle,
|
|
258
|
+
pulseNodeId,
|
|
259
|
+
showPulse = true,
|
|
260
|
+
onShowPulseToggle,
|
|
261
|
+
focusedEpicId,
|
|
262
|
+
onExitFocusedEpic,
|
|
240
263
|
}, ref) {
|
|
241
264
|
const graphRef = useRef<any>(null);
|
|
242
265
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -293,6 +316,9 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
293
316
|
);
|
|
294
317
|
// Color mode ref for paintNode (which has [] deps) to read current color mode
|
|
295
318
|
const colorModeRef = useRef<ColorMode>(colorMode);
|
|
319
|
+
// Pulse node ref: which node to show ripple animation on
|
|
320
|
+
const pulseNodeIdRef = useRef<string | null>(pulseNodeId || null);
|
|
321
|
+
const showPulseRef = useRef<boolean>(showPulse);
|
|
296
322
|
|
|
297
323
|
// Callback ref for refreshing graph when avatar images finish loading
|
|
298
324
|
const avatarRefreshRef = useRef<() => void>(() => {});
|
|
@@ -327,11 +353,64 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
327
353
|
// then removes child nodes and remaps their links to the parent epic.
|
|
328
354
|
// Must be declared BEFORE effects that reference viewNodes/viewLinks.
|
|
329
355
|
const { viewNodes, viewLinks } = useMemo(() => {
|
|
330
|
-
|
|
356
|
+
let currentNodes = nodes;
|
|
357
|
+
let currentLinks = links;
|
|
358
|
+
|
|
359
|
+
// === PHASE 1: Epic focus mode ===
|
|
360
|
+
// When focused on an epic, filter to only the epic's subgraph
|
|
361
|
+
if (focusedEpicId) {
|
|
362
|
+
// Build child->parent map
|
|
363
|
+
const childToParent = new Map<string, string>();
|
|
364
|
+
for (const link of currentLinks) {
|
|
365
|
+
const src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
366
|
+
const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
|
|
367
|
+
if (link.type === "parent-child") {
|
|
368
|
+
childToParent.set(tgt, src);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const nodeIdSet = new Set(currentNodes.map((n) => n.id));
|
|
372
|
+
for (const node of currentNodes) {
|
|
373
|
+
if (!childToParent.has(node.id) && node.id.includes(".")) {
|
|
374
|
+
const parentId = node.id.split(".")[0];
|
|
375
|
+
if (nodeIdSet.has(parentId)) {
|
|
376
|
+
childToParent.set(node.id, parentId);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Collect epic + direct children
|
|
382
|
+
const subgraphIds = new Set<string>();
|
|
383
|
+
subgraphIds.add(focusedEpicId);
|
|
384
|
+
for (const [childId, parentId] of childToParent) {
|
|
385
|
+
if (parentId === focusedEpicId) {
|
|
386
|
+
subgraphIds.add(childId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Add 1-hop neighbors connected via blocks/relates_to links
|
|
391
|
+
for (const link of currentLinks) {
|
|
392
|
+
const src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
393
|
+
const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
|
|
394
|
+
if (link.type !== "parent-child") {
|
|
395
|
+
if (subgraphIds.has(src) && nodeIdSet.has(tgt)) subgraphIds.add(tgt);
|
|
396
|
+
if (subgraphIds.has(tgt) && nodeIdSet.has(src)) subgraphIds.add(src);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
currentNodes = currentNodes.filter((n) => subgraphIds.has(n.id));
|
|
401
|
+
currentLinks = currentLinks.filter((link) => {
|
|
402
|
+
const src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
403
|
+
const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
|
|
404
|
+
return subgraphIds.has(src) && subgraphIds.has(tgt);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// === PHASE 2: Collapse mode ===
|
|
409
|
+
if (!collapsedEpicIds || collapsedEpicIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
|
|
331
410
|
|
|
332
411
|
// Build child->parent map from parent-child links
|
|
333
412
|
const childToParent = new Map<string, string>();
|
|
334
|
-
for (const link of
|
|
413
|
+
for (const link of currentLinks) {
|
|
335
414
|
const src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
336
415
|
const tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
|
|
337
416
|
if (link.type === "parent-child") {
|
|
@@ -340,8 +419,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
340
419
|
}
|
|
341
420
|
}
|
|
342
421
|
// Fallback: infer from hierarchical IDs (e.g., "beads-map-3r3.1" -> parent "beads-map-3r3")
|
|
343
|
-
const nodeIds = new Set(
|
|
344
|
-
for (const node of
|
|
422
|
+
const nodeIds = new Set(currentNodes.map((n) => n.id));
|
|
423
|
+
for (const node of currentNodes) {
|
|
345
424
|
if (!childToParent.has(node.id) && node.id.includes(".")) {
|
|
346
425
|
const parentId = node.id.split(".")[0];
|
|
347
426
|
if (nodeIds.has(parentId)) {
|
|
@@ -358,7 +437,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
358
437
|
}
|
|
359
438
|
}
|
|
360
439
|
|
|
361
|
-
if (childIds.size === 0) return { viewNodes:
|
|
440
|
+
if (childIds.size === 0) return { viewNodes: currentNodes, viewLinks: currentLinks };
|
|
362
441
|
|
|
363
442
|
// Also build a filtered childToParent for only the collapsed children (for link remapping)
|
|
364
443
|
const collapsedChildToParent = new Map<string, string>();
|
|
@@ -372,7 +451,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
372
451
|
const extraDependentCount = new Map<string, number>();
|
|
373
452
|
for (const [childId, parentId] of collapsedChildToParent) {
|
|
374
453
|
collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1);
|
|
375
|
-
const child =
|
|
454
|
+
const child = currentNodes.find((n) => n.id === childId);
|
|
376
455
|
if (child) {
|
|
377
456
|
extraBlockerCount.set(parentId, (extraBlockerCount.get(parentId) || 0) + child.blockerCount);
|
|
378
457
|
extraDependentCount.set(parentId, (extraDependentCount.get(parentId) || 0) + child.dependentCount);
|
|
@@ -380,7 +459,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
380
459
|
}
|
|
381
460
|
|
|
382
461
|
// Filter nodes: remove collapsed children, augment their parents
|
|
383
|
-
const filteredNodes: GraphNode[] =
|
|
462
|
+
const filteredNodes: GraphNode[] = currentNodes
|
|
384
463
|
.filter((n) => !childIds.has(n.id))
|
|
385
464
|
.map((n) => ({
|
|
386
465
|
...n,
|
|
@@ -392,7 +471,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
392
471
|
// Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links
|
|
393
472
|
const remappedLinks: GraphLink[] = [];
|
|
394
473
|
const linkSeen = new Set<string>();
|
|
395
|
-
for (const link of
|
|
474
|
+
for (const link of currentLinks) {
|
|
396
475
|
let src = typeof link.source === "object" ? (link.source as any).id : link.source;
|
|
397
476
|
let tgt = typeof link.target === "object" ? (link.target as any).id : link.target;
|
|
398
477
|
// Drop parent-child links where the child is collapsed
|
|
@@ -408,7 +487,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
408
487
|
}
|
|
409
488
|
|
|
410
489
|
return { viewNodes: filteredNodes, viewLinks: remappedLinks };
|
|
411
|
-
}, [nodes, links, collapsedEpicIds]);
|
|
490
|
+
}, [nodes, links, collapsedEpicIds, focusedEpicId]);
|
|
412
491
|
|
|
413
492
|
// Keep viewNodesRef in sync for mousemove avatar hit-testing
|
|
414
493
|
viewNodesRef.current = viewNodes;
|
|
@@ -463,7 +542,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
463
542
|
|
|
464
543
|
// Compute dynamic legend items based on color mode and visible nodes
|
|
465
544
|
const legendItems = useMemo(() => {
|
|
466
|
-
if (colorMode === "status") return []; // handled by static
|
|
545
|
+
if (colorMode === "status" || colorMode === "priority") return []; // handled by static rendering
|
|
467
546
|
|
|
468
547
|
const items = new Map<string, string>(); // label -> color
|
|
469
548
|
|
|
@@ -552,6 +631,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
552
631
|
minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
|
|
553
632
|
}, [colorMode]);
|
|
554
633
|
|
|
634
|
+
// Sync pulse node ref
|
|
635
|
+
useEffect(() => {
|
|
636
|
+
pulseNodeIdRef.current = pulseNodeId || null;
|
|
637
|
+
showPulseRef.current = showPulse;
|
|
638
|
+
refreshGraph(graphRef);
|
|
639
|
+
}, [pulseNodeId, showPulse]);
|
|
640
|
+
|
|
555
641
|
// Avatar hover detection: mousemove on container, hit-test against avatar positions
|
|
556
642
|
useEffect(() => {
|
|
557
643
|
const container = containerRef.current;
|
|
@@ -830,16 +916,19 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
830
916
|
// Re-heat simulation so new forces take effect immediately
|
|
831
917
|
fg.d3ReheatSimulation();
|
|
832
918
|
|
|
833
|
-
// Fit to view after layout settles
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
919
|
+
// Fit to view after layout settles (only if auto-fit is enabled)
|
|
920
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
921
|
+
if (autoFit) {
|
|
922
|
+
const delay = initialLayoutApplied.current ? 600 : 1000;
|
|
923
|
+
timer = setTimeout(() => {
|
|
924
|
+
if (graphRef.current) graphRef.current.zoomToFit(400, 60);
|
|
925
|
+
}, delay);
|
|
926
|
+
}
|
|
838
927
|
|
|
839
928
|
initialLayoutApplied.current = true;
|
|
840
929
|
|
|
841
|
-
return () => clearTimeout(timer);
|
|
842
|
-
}, [layoutMode, viewNodes, viewLinks]);
|
|
930
|
+
return () => { if (timer) clearTimeout(timer); };
|
|
931
|
+
}, [layoutMode, viewNodes, viewLinks, autoFit]);
|
|
843
932
|
|
|
844
933
|
// Bootstrap trick: start in DAG to spread nodes into good positions,
|
|
845
934
|
// then auto-switch to Force mode. This replicates the exact code path
|
|
@@ -855,16 +944,35 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
855
944
|
return () => clearTimeout(timer);
|
|
856
945
|
}, [ForceGraph2D, nodes.length]);
|
|
857
946
|
|
|
858
|
-
// Fit to view on initial load (skip during timeline replay)
|
|
947
|
+
// Fit to view on initial load (skip during timeline replay or when auto-fit disabled)
|
|
859
948
|
useEffect(() => {
|
|
860
949
|
if (timelineActive) return;
|
|
950
|
+
if (!autoFit) return;
|
|
861
951
|
if (graphRef.current && nodes.length > 0) {
|
|
862
952
|
const timer = setTimeout(() => {
|
|
863
953
|
graphRef.current.zoomToFit(400, 60);
|
|
864
954
|
}, 800);
|
|
865
955
|
return () => clearTimeout(timer);
|
|
866
956
|
}
|
|
867
|
-
}, [nodes.length, timelineActive]);
|
|
957
|
+
}, [nodes.length, timelineActive, autoFit]);
|
|
958
|
+
|
|
959
|
+
// Auto zoom-to-fit when entering/exiting epic focus mode (unconditional)
|
|
960
|
+
const prevFocusedEpicIdRef = useRef<string | null | undefined>(undefined);
|
|
961
|
+
useEffect(() => {
|
|
962
|
+
// Skip on mount (initial render)
|
|
963
|
+
if (prevFocusedEpicIdRef.current === undefined) {
|
|
964
|
+
prevFocusedEpicIdRef.current = focusedEpicId ?? null;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
prevFocusedEpicIdRef.current = focusedEpicId ?? null;
|
|
968
|
+
const graph = graphRef.current;
|
|
969
|
+
if (!graph) return;
|
|
970
|
+
// Small delay to let the force graph process the new node set
|
|
971
|
+
const timer = setTimeout(() => {
|
|
972
|
+
graph.zoomToFit(400, 60);
|
|
973
|
+
}, 100);
|
|
974
|
+
return () => clearTimeout(timer);
|
|
975
|
+
}, [focusedEpicId]);
|
|
868
976
|
|
|
869
977
|
// Memoize graphData so the object reference stays stable across renders.
|
|
870
978
|
// This prevents react-force-graph from treating it as "new data" and
|
|
@@ -938,23 +1046,13 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
938
1046
|
|
|
939
1047
|
const animatedSize = size * animScale;
|
|
940
1048
|
|
|
941
|
-
// Semantic zoom: fade out individual nodes when zoomed out
|
|
942
|
-
// Transition zone: globalScale 0.1 (invisible) → 0.2 (fully visible)
|
|
943
|
-
const FADE_OUT_MIN = 0.1;
|
|
944
|
-
const FADE_OUT_MAX = 0.2;
|
|
945
|
-
const zoomFade = globalScale <= FADE_OUT_MIN
|
|
946
|
-
? 0
|
|
947
|
-
: globalScale >= FADE_OUT_MAX
|
|
948
|
-
? 1
|
|
949
|
-
: (globalScale - FADE_OUT_MIN) / (FADE_OUT_MAX - FADE_OUT_MIN);
|
|
950
|
-
|
|
951
1049
|
// Opacity: dim non-connected nodes when highlighting
|
|
952
1050
|
const opacity =
|
|
953
1051
|
(hasHighlight && !isConnected
|
|
954
1052
|
? 0.15
|
|
955
1053
|
: graphNode.status === "closed"
|
|
956
1054
|
? 0.5
|
|
957
|
-
: 1) * removeOpacity
|
|
1055
|
+
: 1) * removeOpacity;
|
|
958
1056
|
|
|
959
1057
|
if (opacity <= 0.01) return; // skip fully faded nodes
|
|
960
1058
|
|
|
@@ -1014,6 +1112,30 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
1014
1112
|
}
|
|
1015
1113
|
}
|
|
1016
1114
|
|
|
1115
|
+
// --- Activity pulse ripple (continuous, on most-recently-active node) ---
|
|
1116
|
+
if (showPulseRef.current && pulseNodeIdRef.current === graphNode.id) {
|
|
1117
|
+
const RIPPLE_PERIOD = 2000; // full cycle in ms
|
|
1118
|
+
const RIPPLE_COUNT = 3;
|
|
1119
|
+
const RIPPLE_STAGGER = 500; // ms between each ring
|
|
1120
|
+
// Scale ripple to ~30 screen pixels regardless of zoom level
|
|
1121
|
+
const maxExpand = Math.max(25, 30 / globalScale);
|
|
1122
|
+
const MAX_RIPPLE_RADIUS = animatedSize + maxExpand;
|
|
1123
|
+
|
|
1124
|
+
for (let i = 0; i < RIPPLE_COUNT; i++) {
|
|
1125
|
+
const phase = ((now + i * RIPPLE_STAGGER) % RIPPLE_PERIOD) / RIPPLE_PERIOD;
|
|
1126
|
+
const rippleRadius = animatedSize + 2 / globalScale + phase * (MAX_RIPPLE_RADIUS - animatedSize);
|
|
1127
|
+
const rippleOpacity = (1 - phase) * 0.6;
|
|
1128
|
+
|
|
1129
|
+
ctx.beginPath();
|
|
1130
|
+
ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2);
|
|
1131
|
+
ctx.strokeStyle = "#10b981"; // emerald-500
|
|
1132
|
+
ctx.lineWidth = Math.max(1.5 / globalScale, 0.5);
|
|
1133
|
+
ctx.globalAlpha = rippleOpacity * opacity;
|
|
1134
|
+
ctx.stroke();
|
|
1135
|
+
}
|
|
1136
|
+
ctx.globalAlpha = opacity; // reset
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1017
1139
|
// --- Spawn glow ---
|
|
1018
1140
|
if (spawnTime) {
|
|
1019
1141
|
const elapsed = now - spawnTime;
|
|
@@ -1223,15 +1345,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
1223
1345
|
const isConnectedLink =
|
|
1224
1346
|
hasHighlight && connected.has(srcId) && connected.has(tgtId);
|
|
1225
1347
|
|
|
1226
|
-
// Semantic zoom: fade out links when zoomed out (same range as nodes)
|
|
1227
|
-
const LINK_FADE_OUT_MIN = 0.1;
|
|
1228
|
-
const LINK_FADE_OUT_MAX = 0.2;
|
|
1229
|
-
const linkZoomFade = globalScale <= LINK_FADE_OUT_MIN
|
|
1230
|
-
? 0
|
|
1231
|
-
: globalScale >= LINK_FADE_OUT_MAX
|
|
1232
|
-
? 1
|
|
1233
|
-
: (globalScale - LINK_FADE_OUT_MIN) / (LINK_FADE_OUT_MAX - LINK_FADE_OUT_MIN);
|
|
1234
|
-
|
|
1235
1348
|
// Parent-child links are more subtle
|
|
1236
1349
|
const opacity = (isParentChild
|
|
1237
1350
|
? hasHighlight
|
|
@@ -1239,7 +1352,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
1239
1352
|
: 0.2
|
|
1240
1353
|
: hasHighlight
|
|
1241
1354
|
? isConnectedLink ? 0.8 : 0.08
|
|
1242
|
-
: 0.35) * linkAnimAlpha
|
|
1355
|
+
: 0.35) * linkAnimAlpha;
|
|
1243
1356
|
|
|
1244
1357
|
if (opacity <= 0.01) return; // skip fully faded links
|
|
1245
1358
|
|
|
@@ -1330,7 +1443,6 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
1330
1443
|
|
|
1331
1444
|
// Semantic zoom: draw epic/cluster labels when zoomed out far.
|
|
1332
1445
|
// Computes centroids from live node positions and draws titles.
|
|
1333
|
-
// Appears as nodes fade out (inverse of node fade).
|
|
1334
1446
|
const paintClusterLabels = useCallback(
|
|
1335
1447
|
(ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
1336
1448
|
if (!showClusters) return;
|
|
@@ -1786,230 +1898,293 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
1786
1898
|
);
|
|
1787
1899
|
|
|
1788
1900
|
return (
|
|
1789
|
-
<div ref={containerRef} className="w-full h-full relative">
|
|
1901
|
+
<div ref={containerRef} className="w-full h-full relative" data-tutorial="graph">
|
|
1790
1902
|
{/* Top-left controls */}
|
|
1791
|
-
<div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex
|
|
1792
|
-
{/*
|
|
1793
|
-
|
|
1794
|
-
<
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
}`}
|
|
1801
|
-
>
|
|
1802
|
-
<span className="flex items-center gap-1.5">
|
|
1803
|
-
<svg
|
|
1804
|
-
className="w-3.5 h-3.5"
|
|
1805
|
-
viewBox="0 0 16 16"
|
|
1806
|
-
fill="none"
|
|
1807
|
-
stroke="currentColor"
|
|
1808
|
-
strokeWidth="1.5"
|
|
1809
|
-
>
|
|
1810
|
-
{/* Spring/force icon: scattered dots with connections */}
|
|
1811
|
-
<circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
|
|
1812
|
-
<circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
|
|
1813
|
-
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1814
|
-
<circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
|
1815
|
-
<circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
|
1816
|
-
<line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1817
|
-
<line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1818
|
-
<line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1819
|
-
<line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1820
|
-
</svg>
|
|
1821
|
-
<span className="hidden sm:inline">Force</span>
|
|
1822
|
-
</span>
|
|
1823
|
-
</button>
|
|
1824
|
-
<div className="w-px bg-zinc-200" />
|
|
1825
|
-
<button
|
|
1826
|
-
onClick={() => setLayoutMode("dag")}
|
|
1827
|
-
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1828
|
-
layoutMode === "dag"
|
|
1829
|
-
? "bg-emerald-500 text-white"
|
|
1830
|
-
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1831
|
-
}`}
|
|
1832
|
-
>
|
|
1833
|
-
<span className="flex items-center gap-1.5">
|
|
1834
|
-
<svg
|
|
1835
|
-
className="w-3.5 h-3.5"
|
|
1836
|
-
viewBox="0 0 16 16"
|
|
1837
|
-
fill="none"
|
|
1838
|
-
stroke="currentColor"
|
|
1839
|
-
strokeWidth="1.5"
|
|
1840
|
-
>
|
|
1841
|
-
{/* Tree/DAG icon: top-down hierarchy */}
|
|
1842
|
-
<circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1843
|
-
<circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1844
|
-
<circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1845
|
-
<circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1846
|
-
<circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1847
|
-
<circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1848
|
-
<line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
|
|
1849
|
-
<line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
|
|
1850
|
-
<line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
|
|
1851
|
-
<line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
|
|
1852
|
-
<line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
|
|
1853
|
-
</svg>
|
|
1854
|
-
<span className="hidden sm:inline">DAG</span>
|
|
1855
|
-
</span>
|
|
1856
|
-
</button>
|
|
1857
|
-
<div className="w-px bg-zinc-200" />
|
|
1858
|
-
<button
|
|
1859
|
-
onClick={() => setLayoutMode("radial")}
|
|
1860
|
-
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1861
|
-
layoutMode === "radial"
|
|
1862
|
-
? "bg-emerald-500 text-white"
|
|
1863
|
-
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1864
|
-
}`}
|
|
1865
|
-
>
|
|
1866
|
-
<span className="flex items-center gap-1.5">
|
|
1867
|
-
<svg
|
|
1868
|
-
className="w-3.5 h-3.5"
|
|
1869
|
-
viewBox="0 0 16 16"
|
|
1870
|
-
fill="none"
|
|
1871
|
-
stroke="currentColor"
|
|
1872
|
-
strokeWidth="1.5"
|
|
1873
|
-
>
|
|
1874
|
-
{/* Radial icon: concentric rings with center dot */}
|
|
1875
|
-
<circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
|
|
1876
|
-
<circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
|
|
1877
|
-
<circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
|
|
1878
|
-
</svg>
|
|
1879
|
-
<span className="hidden sm:inline">Radial</span>
|
|
1880
|
-
</span>
|
|
1881
|
-
</button>
|
|
1882
|
-
<div className="w-px bg-zinc-200" />
|
|
1883
|
-
<button
|
|
1884
|
-
onClick={() => setLayoutMode("cluster")}
|
|
1885
|
-
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1886
|
-
layoutMode === "cluster"
|
|
1887
|
-
? "bg-emerald-500 text-white"
|
|
1888
|
-
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1889
|
-
}`}
|
|
1890
|
-
>
|
|
1891
|
-
<span className="flex items-center gap-1.5">
|
|
1892
|
-
<svg
|
|
1893
|
-
className="w-3.5 h-3.5"
|
|
1894
|
-
viewBox="0 0 16 16"
|
|
1895
|
-
fill="none"
|
|
1896
|
-
stroke="currentColor"
|
|
1897
|
-
strokeWidth="1.5"
|
|
1898
|
-
>
|
|
1899
|
-
{/* Cluster icon: two groups of dots */}
|
|
1900
|
-
<circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
|
|
1901
|
-
<circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
|
|
1902
|
-
<circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
|
|
1903
|
-
<circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
|
1904
|
-
<circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1905
|
-
<circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
|
1906
|
-
</svg>
|
|
1907
|
-
<span className="hidden sm:inline">Cluster</span>
|
|
1903
|
+
<div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2">
|
|
1904
|
+
{/* Focus mode banner */}
|
|
1905
|
+
{focusedEpicId && onExitFocusedEpic && (
|
|
1906
|
+
<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">
|
|
1907
|
+
<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">
|
|
1908
|
+
<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" />
|
|
1909
|
+
</svg>
|
|
1910
|
+
<span className="truncate max-w-[180px]">
|
|
1911
|
+
Focused: <span className="font-semibold">{nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}</span>
|
|
1908
1912
|
</span>
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1917
|
-
}`}
|
|
1918
|
-
>
|
|
1919
|
-
<span className="flex items-center gap-1.5">
|
|
1920
|
-
<svg
|
|
1921
|
-
className="w-3.5 h-3.5"
|
|
1922
|
-
viewBox="0 0 16 16"
|
|
1923
|
-
fill="none"
|
|
1924
|
-
stroke="currentColor"
|
|
1925
|
-
strokeWidth="1.5"
|
|
1926
|
-
>
|
|
1927
|
-
{/* Spread icon: dots spread far apart */}
|
|
1928
|
-
<circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
|
|
1929
|
-
<circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
|
|
1930
|
-
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1931
|
-
<circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
|
|
1932
|
-
<circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
|
1913
|
+
<button
|
|
1914
|
+
onClick={onExitFocusedEpic}
|
|
1915
|
+
className="ml-auto p-0.5 rounded hover:bg-emerald-200/50 transition-colors flex-shrink-0"
|
|
1916
|
+
title="Show full graph"
|
|
1917
|
+
>
|
|
1918
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
1919
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
1933
1920
|
</svg>
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
</div>
|
|
1921
|
+
</button>
|
|
1922
|
+
</div>
|
|
1923
|
+
)}
|
|
1938
1924
|
|
|
1939
|
-
{/*
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1925
|
+
{/* Row 1: Layout shape controls */}
|
|
1926
|
+
<div className="flex items-start gap-1.5 sm:gap-2">
|
|
1927
|
+
{/* Layout mode toggle */}
|
|
1928
|
+
<div className="flex bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm overflow-hidden" data-tutorial="layouts">
|
|
1929
|
+
<button
|
|
1930
|
+
onClick={() => setLayoutMode("force")}
|
|
1931
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1932
|
+
layoutMode === "force"
|
|
1933
|
+
? "bg-emerald-500 text-white"
|
|
1934
|
+
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1935
|
+
}`}
|
|
1936
|
+
>
|
|
1937
|
+
<span className="flex items-center gap-1.5">
|
|
1947
1938
|
<svg
|
|
1948
1939
|
className="w-3.5 h-3.5"
|
|
1949
|
-
viewBox="0 0
|
|
1940
|
+
viewBox="0 0 16 16"
|
|
1950
1941
|
fill="none"
|
|
1951
|
-
strokeWidth={1.5}
|
|
1952
1942
|
stroke="currentColor"
|
|
1943
|
+
strokeWidth="1.5"
|
|
1953
1944
|
>
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
/>
|
|
1945
|
+
{/* Spring/force icon: scattered dots with connections */}
|
|
1946
|
+
<circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
|
|
1947
|
+
<circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
|
|
1948
|
+
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1949
|
+
<circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
|
1950
|
+
<circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
|
|
1951
|
+
<line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1952
|
+
<line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1953
|
+
<line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1954
|
+
<line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
|
|
1959
1955
|
</svg>
|
|
1960
|
-
<span className="hidden sm:inline">
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1956
|
+
<span className="hidden sm:inline">Force</span>
|
|
1957
|
+
</span>
|
|
1958
|
+
</button>
|
|
1959
|
+
<div className="w-px bg-zinc-200" />
|
|
1960
|
+
<button
|
|
1961
|
+
onClick={() => setLayoutMode("dag")}
|
|
1962
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1963
|
+
layoutMode === "dag"
|
|
1964
|
+
? "bg-emerald-500 text-white"
|
|
1965
|
+
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1966
|
+
}`}
|
|
1967
|
+
>
|
|
1968
|
+
<span className="flex items-center gap-1.5">
|
|
1964
1969
|
<svg
|
|
1965
1970
|
className="w-3.5 h-3.5"
|
|
1966
|
-
viewBox="0 0
|
|
1971
|
+
viewBox="0 0 16 16"
|
|
1967
1972
|
fill="none"
|
|
1968
|
-
strokeWidth={1.5}
|
|
1969
1973
|
stroke="currentColor"
|
|
1974
|
+
strokeWidth="1.5"
|
|
1970
1975
|
>
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
/>
|
|
1976
|
+
{/* Tree/DAG icon: top-down hierarchy */}
|
|
1977
|
+
<circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1978
|
+
<circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1979
|
+
<circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
1980
|
+
<circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1981
|
+
<circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1982
|
+
<circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
|
|
1983
|
+
<line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
|
|
1984
|
+
<line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
|
|
1985
|
+
<line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
|
|
1986
|
+
<line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
|
|
1987
|
+
<line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
|
|
1976
1988
|
</svg>
|
|
1977
|
-
<span className="hidden sm:inline">
|
|
1978
|
-
|
|
1979
|
-
|
|
1989
|
+
<span className="hidden sm:inline">DAG</span>
|
|
1990
|
+
</span>
|
|
1991
|
+
</button>
|
|
1992
|
+
<div className="w-px bg-zinc-200" />
|
|
1993
|
+
<button
|
|
1994
|
+
onClick={() => setLayoutMode("radial")}
|
|
1995
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
1996
|
+
layoutMode === "radial"
|
|
1997
|
+
? "bg-emerald-500 text-white"
|
|
1998
|
+
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
1999
|
+
}`}
|
|
2000
|
+
>
|
|
2001
|
+
<span className="flex items-center gap-1.5">
|
|
2002
|
+
<svg
|
|
2003
|
+
className="w-3.5 h-3.5"
|
|
2004
|
+
viewBox="0 0 16 16"
|
|
2005
|
+
fill="none"
|
|
2006
|
+
stroke="currentColor"
|
|
2007
|
+
strokeWidth="1.5"
|
|
2008
|
+
>
|
|
2009
|
+
{/* Radial icon: concentric rings with center dot */}
|
|
2010
|
+
<circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
|
|
2011
|
+
<circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
|
|
2012
|
+
<circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
|
|
2013
|
+
</svg>
|
|
2014
|
+
<span className="hidden sm:inline">Radial</span>
|
|
2015
|
+
</span>
|
|
2016
|
+
</button>
|
|
2017
|
+
<div className="w-px bg-zinc-200" />
|
|
2018
|
+
<button
|
|
2019
|
+
onClick={() => setLayoutMode("cluster")}
|
|
2020
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
2021
|
+
layoutMode === "cluster"
|
|
2022
|
+
? "bg-emerald-500 text-white"
|
|
2023
|
+
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
2024
|
+
}`}
|
|
2025
|
+
>
|
|
2026
|
+
<span className="flex items-center gap-1.5">
|
|
2027
|
+
<svg
|
|
2028
|
+
className="w-3.5 h-3.5"
|
|
2029
|
+
viewBox="0 0 16 16"
|
|
2030
|
+
fill="none"
|
|
2031
|
+
stroke="currentColor"
|
|
2032
|
+
strokeWidth="1.5"
|
|
2033
|
+
>
|
|
2034
|
+
{/* Cluster icon: two groups of dots */}
|
|
2035
|
+
<circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
|
|
2036
|
+
<circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
|
|
2037
|
+
<circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
|
|
2038
|
+
<circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
|
|
2039
|
+
<circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
|
|
2040
|
+
<circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
|
2041
|
+
</svg>
|
|
2042
|
+
<span className="hidden sm:inline">Cluster</span>
|
|
2043
|
+
</span>
|
|
2044
|
+
</button>
|
|
2045
|
+
<div className="w-px bg-zinc-200" />
|
|
2046
|
+
<button
|
|
2047
|
+
onClick={() => setLayoutMode("spread")}
|
|
2048
|
+
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
2049
|
+
layoutMode === "spread"
|
|
2050
|
+
? "bg-emerald-500 text-white"
|
|
2051
|
+
: "text-zinc-500 hover:text-zinc-700 hover:bg-zinc-50"
|
|
2052
|
+
}`}
|
|
2053
|
+
>
|
|
2054
|
+
<span className="flex items-center gap-1.5">
|
|
2055
|
+
<svg
|
|
2056
|
+
className="w-3.5 h-3.5"
|
|
2057
|
+
viewBox="0 0 16 16"
|
|
2058
|
+
fill="none"
|
|
2059
|
+
stroke="currentColor"
|
|
2060
|
+
strokeWidth="1.5"
|
|
2061
|
+
>
|
|
2062
|
+
{/* Spread icon: dots spread far apart */}
|
|
2063
|
+
<circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
|
|
2064
|
+
<circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
|
|
2065
|
+
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
|
|
2066
|
+
<circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
|
|
2067
|
+
<circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
|
|
2068
|
+
</svg>
|
|
2069
|
+
<span className="hidden sm:inline">Spread</span>
|
|
2070
|
+
</span>
|
|
2071
|
+
</button>
|
|
2072
|
+
</div>
|
|
2073
|
+
</div>
|
|
2074
|
+
|
|
2075
|
+
{/* Row 2: View toggles */}
|
|
2076
|
+
<div className="flex items-start gap-1.5 sm:gap-2" data-tutorial="view-controls">
|
|
2077
|
+
{/* Collapse / Expand all toggle */}
|
|
2078
|
+
{(onCollapseAll || onExpandAll) && (
|
|
2079
|
+
<button
|
|
2080
|
+
onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
|
|
2081
|
+
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"
|
|
2082
|
+
>
|
|
2083
|
+
{collapsedEpicIds && collapsedEpicIds.size > 0 ? (
|
|
2084
|
+
<>
|
|
2085
|
+
<svg
|
|
2086
|
+
className="w-3.5 h-3.5"
|
|
2087
|
+
viewBox="0 0 24 24"
|
|
2088
|
+
fill="none"
|
|
2089
|
+
strokeWidth={1.5}
|
|
2090
|
+
stroke="currentColor"
|
|
2091
|
+
>
|
|
2092
|
+
<path
|
|
2093
|
+
strokeLinecap="round"
|
|
2094
|
+
strokeLinejoin="round"
|
|
2095
|
+
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"
|
|
2096
|
+
/>
|
|
2097
|
+
</svg>
|
|
2098
|
+
<span className="hidden sm:inline">Expand all</span>
|
|
2099
|
+
</>
|
|
2100
|
+
) : (
|
|
2101
|
+
<>
|
|
2102
|
+
<svg
|
|
2103
|
+
className="w-3.5 h-3.5"
|
|
2104
|
+
viewBox="0 0 24 24"
|
|
2105
|
+
fill="none"
|
|
2106
|
+
strokeWidth={1.5}
|
|
2107
|
+
stroke="currentColor"
|
|
2108
|
+
>
|
|
2109
|
+
<path
|
|
2110
|
+
strokeLinecap="round"
|
|
2111
|
+
strokeLinejoin="round"
|
|
2112
|
+
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"
|
|
2113
|
+
/>
|
|
2114
|
+
</svg>
|
|
2115
|
+
<span className="hidden sm:inline">Collapse all</span>
|
|
2116
|
+
</>
|
|
2117
|
+
)}
|
|
2118
|
+
</button>
|
|
2119
|
+
)}
|
|
2120
|
+
|
|
2121
|
+
{/* Show/hide cluster labels toggle */}
|
|
2122
|
+
<button
|
|
2123
|
+
onClick={() => setShowClusters((v) => !v)}
|
|
2124
|
+
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 ${
|
|
2125
|
+
showClusters
|
|
2126
|
+
? "bg-emerald-500 text-white border-emerald-500"
|
|
2127
|
+
: "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
|
|
2128
|
+
}`}
|
|
2129
|
+
title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
|
|
2130
|
+
>
|
|
2131
|
+
<svg
|
|
2132
|
+
className="w-3.5 h-3.5"
|
|
2133
|
+
viewBox="0 0 16 16"
|
|
2134
|
+
fill="none"
|
|
2135
|
+
stroke="currentColor"
|
|
2136
|
+
strokeWidth="1.5"
|
|
2137
|
+
>
|
|
2138
|
+
{/* Dashed circle with label lines — cluster overlay icon */}
|
|
2139
|
+
<circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
|
|
2140
|
+
<line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
|
|
2141
|
+
<line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
|
|
2142
|
+
</svg>
|
|
2143
|
+
<span className="hidden sm:inline">Clusters</span>
|
|
1980
2144
|
</button>
|
|
1981
|
-
)}
|
|
1982
2145
|
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
>
|
|
1993
|
-
<svg
|
|
1994
|
-
className="w-3.5 h-3.5"
|
|
1995
|
-
viewBox="0 0 16 16"
|
|
1996
|
-
fill="none"
|
|
1997
|
-
stroke="currentColor"
|
|
1998
|
-
strokeWidth="1.5"
|
|
2146
|
+
{/* Auto-fit: lock/unlock automatic camera reframing */}
|
|
2147
|
+
<button
|
|
2148
|
+
onClick={() => onAutoFitToggle?.()}
|
|
2149
|
+
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 ${
|
|
2150
|
+
autoFit
|
|
2151
|
+
? "bg-emerald-500 text-white border-emerald-500"
|
|
2152
|
+
: "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
|
|
2153
|
+
}`}
|
|
2154
|
+
title={autoFit ? "Auto-fit enabled: camera adjusts after updates" : "Auto-fit disabled: camera stays fixed"}
|
|
1999
2155
|
>
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2156
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
2157
|
+
<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" />
|
|
2158
|
+
<circle cx="12" cy="12" r="3" />
|
|
2159
|
+
</svg>
|
|
2160
|
+
<span className="hidden sm:inline">Auto-fit</span>
|
|
2161
|
+
</button>
|
|
2162
|
+
|
|
2163
|
+
{/* Pulse: highlight most recently active node */}
|
|
2164
|
+
<button
|
|
2165
|
+
onClick={() => onShowPulseToggle?.()}
|
|
2166
|
+
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 ${
|
|
2167
|
+
showPulse
|
|
2168
|
+
? "bg-emerald-500 text-white border-emerald-500"
|
|
2169
|
+
: "bg-white/90 text-zinc-500 border-zinc-200 hover:text-zinc-700 hover:bg-zinc-50"
|
|
2170
|
+
}`}
|
|
2171
|
+
title={showPulse ? "Pulse enabled: ripple highlights most recent activity" : "Pulse disabled: no activity highlight"}
|
|
2172
|
+
>
|
|
2173
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
2174
|
+
<circle cx="12" cy="12" r="3" />
|
|
2175
|
+
<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" />
|
|
2176
|
+
<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" />
|
|
2177
|
+
</svg>
|
|
2178
|
+
<span className="hidden sm:inline">Pulse</span>
|
|
2179
|
+
</button>
|
|
2180
|
+
</div>
|
|
2007
2181
|
</div>
|
|
2008
2182
|
|
|
2009
2183
|
{/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */}
|
|
2010
2184
|
{!timelineActive && (
|
|
2011
2185
|
<div
|
|
2012
2186
|
className="absolute bottom-4 z-10 bg-white/90 backdrop-blur-sm rounded-lg border border-zinc-200 shadow-sm px-3 py-2 text-xs text-zinc-400 transition-[right] duration-300 ease-out"
|
|
2187
|
+
data-tutorial="legend"
|
|
2013
2188
|
style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem", maxWidth: 320 }}
|
|
2014
2189
|
>
|
|
2015
2190
|
{stats && (
|
|
@@ -2024,7 +2199,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
2024
2199
|
)}
|
|
2025
2200
|
{/* Color mode segmented control */}
|
|
2026
2201
|
<div className="hidden sm:flex bg-zinc-100 rounded-md overflow-hidden mb-1.5">
|
|
2027
|
-
{(["status", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
|
|
2202
|
+
{(["status", "priority", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
|
|
2028
2203
|
<button
|
|
2029
2204
|
key={mode}
|
|
2030
2205
|
onClick={() => onColorModeChange?.(mode)}
|
|
@@ -2038,7 +2213,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
2038
2213
|
</button>
|
|
2039
2214
|
))}
|
|
2040
2215
|
</div>
|
|
2041
|
-
{/* Dynamic legend: status dots or person/prefix dots */}
|
|
2216
|
+
{/* Dynamic legend: status/priority dots or person/prefix dots */}
|
|
2042
2217
|
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5">
|
|
2043
2218
|
{colorMode === "status" ? (
|
|
2044
2219
|
<>
|
|
@@ -2052,6 +2227,18 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
2052
2227
|
</span>
|
|
2053
2228
|
))}
|
|
2054
2229
|
</>
|
|
2230
|
+
) : colorMode === "priority" ? (
|
|
2231
|
+
<>
|
|
2232
|
+
{[0, 1, 2, 3, 4].map((p) => (
|
|
2233
|
+
<span key={p} className="flex items-center gap-1">
|
|
2234
|
+
<span
|
|
2235
|
+
className="w-2 h-2 rounded-full"
|
|
2236
|
+
style={{ backgroundColor: PRIORITY_COLORS[p] }}
|
|
2237
|
+
/>
|
|
2238
|
+
<span className="text-zinc-500">{PRIORITY_LABELS[p]}</span>
|
|
2239
|
+
</span>
|
|
2240
|
+
))}
|
|
2241
|
+
</>
|
|
2055
2242
|
) : (
|
|
2056
2243
|
<>
|
|
2057
2244
|
{legendItems.map(({ label, color }) => (
|
|
@@ -2079,6 +2266,7 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
2079
2266
|
{/* Minimap — bottom-left, hidden on mobile, resizable */}
|
|
2080
2267
|
<div
|
|
2081
2268
|
className="hidden sm:block absolute bottom-4 left-4 z-10"
|
|
2269
|
+
data-tutorial="minimap"
|
|
2082
2270
|
style={{ width: MINIMAP_W, height: MINIMAP_H }}
|
|
2083
2271
|
>
|
|
2084
2272
|
<canvas
|
|
@@ -2220,6 +2408,8 @@ const BeadsGraph = forwardRef<BeadsGraphHandle, BeadsGraphProps>(function BeadsG
|
|
|
2220
2408
|
onZoom={handleZoom}
|
|
2221
2409
|
// Background
|
|
2222
2410
|
backgroundColor="transparent"
|
|
2411
|
+
// Disable auto-pause when pulse is active so canvas redraws every frame
|
|
2412
|
+
autoPauseRedraw={!(showPulse && pulseNodeId)}
|
|
2223
2413
|
/>
|
|
2224
2414
|
) : (
|
|
2225
2415
|
<div className="flex items-center justify-center h-full">
|