"use client"; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { forceCollide, forceRadial, forceX, forceY } from "d3-force"; import type { GraphNode, GraphLink, ColorMode } from "@/lib/types"; import { STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS, PRIORITY_LABELS, COLOR_MODE_LABELS, TYPE_COLORS, TYPE_LABELS, getPersonColor, getCatppuccinPrefixColor, getPrefixLabel, } from "@/lib/types"; // Lazy-load ForceGraph2D client-side (it requires window/document). // We avoid next/dynamic because it wraps the component in a LoadableComponent // that does NOT forward refs, breaking graphRef.current.centerAt/zoom/etc. let _ForceGraph2DModule: React.ComponentType | null = null; type LayoutMode = "force" | "dag" | "radial" | "cluster" | "spread"; export interface BeadsGraphHandle { focusNode: (node: GraphNode) => void; zoomToNode: (nodeId: string) => void; } interface BeadsGraphProps { nodes: GraphNode[]; links: GraphLink[]; selectedNode: GraphNode | null; onNodeClick: (node: GraphNode, event?: MouseEvent) => void; onNodeHover: (node: GraphNode | null, x: number, y: number) => void; onBackgroundClick: () => void; onNodeRightClick?: (node: GraphNode, event: MouseEvent) => void; /** Set of multi-selected node IDs (from marquee or shift-click) */ selectedNodeIds?: Set; /** Callback to update multi-selected node IDs */ onSelectedNodeIdsChange?: (ids: Set) => void; commentedNodeIds?: Map; claimedNodeAvatars?: Map; assigneeNodeAvatars?: Map; onAvatarHover?: (info: { handle: string; avatar?: string; claimedAt: string; did?: string; x: number; y: number; isAssignee?: boolean; nodeId?: string; nodeClosed?: boolean } | null) => void; /** Called when user clicks on a claimed/assignee avatar on a node */ onAvatarClick?: (info: { handle: string; avatar?: string; did?: string; nodeId: string; isAssignee?: boolean }) => void; timelineActive?: boolean; stats?: { total: number; edges: number; prefixes: string[] }; /** When a right sidebar (NodeDetail, Comments, Activity) is open, shift bottom-right legend inward */ sidebarOpen?: boolean; /** Set of epic IDs that are currently collapsed */ collapsedEpicIds?: Set; /** Collapse all epics at once */ onCollapseAll?: () => void; /** Expand all epics at once */ onExpandAll?: () => void; /** Current color mode for node body fill */ colorMode?: ColorMode; /** Callback to change color mode (from legend selector) */ onColorModeChange?: (mode: ColorMode) => void; /** Whether to auto-zoom to fit all nodes after data updates and layout changes */ autoFit?: boolean; /** Callback to toggle auto-fit */ onAutoFitToggle?: () => void; /** Node ID to show a pulsing ripple on (most recently active node) */ pulseNodeId?: string | null; /** Whether pulse animation is enabled */ showPulse?: boolean; /** Callback to toggle pulse animation */ onShowPulseToggle?: () => void; /** Whether flow particles are enabled on dependency links */ showParticles?: boolean; /** Callback to toggle flow particles */ onShowParticlesToggle?: () => void; /** When set, only show this epic and its connected subgraph */ focusedEpicId?: string | null; /** Callback to exit focused epic mode */ onExitFocusedEpic?: () => void; /** Whether the viewport is mobile (<=768px) — enables double-tap detection */ isMobile?: boolean; /** Callback for double-tap on a node (mobile context menu) */ onNodeDoubleTap?: (node: GraphNode, x: number, y: number) => void; /** Set of active legend filter labels */ activeLegendFilters?: Set; /** Callback to toggle a legend filter label */ onToggleLegendFilter?: (label: string) => void; /** Callback to clear all legend filters */ onClearLegendFilters?: () => void; /** Current theme (light or dark) */ theme?: string; } // Node size calculation function getNodeSize(node: GraphNode): number { const MIN_SIZE = 5; const MAX_SIZE = 22; const connections = node.blockerCount + node.dependentCount; // Epics get a base boost let score = connections; if (node.issueType === "epic") score += 3; // Normalize: 0 connections -> MIN, 6+ -> MAX const normalized = Math.min(score / 6, 1); return MIN_SIZE + normalized * (MAX_SIZE - MIN_SIZE); } // Module-level color mode tracker (synced from component via useEffect) let _currentColorMode: ColorMode = "status"; // Get color based on current color mode function getNodeColor(node: GraphNode): string { switch (_currentColorMode) { case "priority": return PRIORITY_COLORS[node.priority] || PRIORITY_COLORS[2]; case "type": return TYPE_COLORS[node.issueType] || TYPE_COLORS.task; case "owner": return getPersonColor(node.createdBy); case "assignee": return getPersonColor(node.assignee); case "prefix": return getCatppuccinPrefixColor(node.prefix); case "status": default: return STATUS_COLORS[node.status] || STATUS_COLORS.open; } } // Get prefix color for the outer ring — uses Catppuccin palette for consistency function getPrefixRingColor(node: GraphNode): string { return getCatppuccinPrefixColor(node.prefix); } // Animation duration constants const SPAWN_DURATION = 500; // ms for pop-in animation const REMOVE_DURATION = 400; // ms for shrink-out animation const CHANGE_DURATION = 800; // ms for status change ripple /** * easeOutBack: overshoots slightly then settles — gives "pop" feel. */ function easeOutBack(t: number): number { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); } /** * easeOutQuad: smooth deceleration. */ function easeOutQuad(t: number): number { return 1 - (1 - t) * (1 - t); } // --- Module-level avatar image cache for canvas rendering --- const avatarImageCache = new Map< string, HTMLImageElement | "loading" | "failed" >(); function getAvatarImage( url: string, onLoad: () => void ): HTMLImageElement | null { const cached = avatarImageCache.get(url); if (cached === "loading" || cached === "failed") return null; if (cached) return cached; avatarImageCache.set(url, "loading"); const img = new Image(); img.onload = () => { avatarImageCache.set(url, img); onLoad(); }; img.onerror = () => { avatarImageCache.set(url, "failed"); }; img.src = url; return null; } function drawAvatarFallback( ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, handle: string, globalScale: number, isDark: boolean ) { ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = isDark ? "#3f3f46" : "#e4e4e7"; // zinc-700 / zinc-200 ctx.fill(); const letter = handle.replace("@", "").charAt(0).toUpperCase(); const fontSize = Math.min(7, Math.max(3, radius * 1.3)); ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#71717a"; // zinc-500 (works on both backgrounds) ctx.fillText(letter, x, y + 0.3); } /** * Compute connected subgraph via BFS (depth 2) - pure function, no React state */ function computeConnectedNodes( targetNodeId: string, links: GraphLink[] ): Set { const connected = new Set([targetNodeId]); const queue = [{ id: targetNodeId, depth: 0 }]; while (queue.length > 0) { const { id, depth } = queue.shift()!; if (depth >= 2) continue; for (const link of links) { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (src === id && !connected.has(tgt)) { connected.add(tgt); queue.push({ id: tgt, depth: depth + 1 }); } if (tgt === id && !connected.has(src)) { connected.add(src); queue.push({ id: src, depth: depth + 1 }); } } } return connected; } /** * Imperceptible zoom trick to force canvas redraw without re-heating simulation. * Borrowed from the reference beads map (graph.js refreshGraph()). */ function refreshGraph(graphRef: React.RefObject) { const graph = graphRef.current; if (!graph) return; const currentZoom = graph.zoom(); if (typeof currentZoom !== "number" || isNaN(currentZoom)) return; graph.zoom(currentZoom * 1.000001, 0); requestAnimationFrame(() => { if (graphRef.current) graphRef.current.zoom(currentZoom, 0); }); } // Stable function references for ForceGraph2D props (avoid re-init on re-render) const REPLACE_MODE = () => "replace" as const; const PARTICLE_COLOR = () => "#10b981"; const BeadsGraph = forwardRef(function BeadsGraph({ nodes, links, selectedNode, selectedNodeIds, onSelectedNodeIdsChange, onNodeClick, onNodeHover, onBackgroundClick, onNodeRightClick, commentedNodeIds, claimedNodeAvatars, assigneeNodeAvatars, onAvatarHover, onAvatarClick, timelineActive, stats, sidebarOpen, collapsedEpicIds, onCollapseAll, onExpandAll, colorMode = "status", onColorModeChange, autoFit = true, onAutoFitToggle, pulseNodeId, showPulse = true, onShowPulseToggle, showParticles = true, onShowParticlesToggle, focusedEpicId, onExitFocusedEpic, isMobile, onNodeDoubleTap, activeLegendFilters, onToggleLegendFilter, onClearLegendFilters, theme, }, ref) { const graphRef = useRef(null); const containerRef = useRef(null); const minimapCanvasRef = useRef(null); const minimapRafRef = useRef(0); const redrawMinimapRef = useRef<() => void>(() => {}); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const initialLayoutApplied = useRef(false); const selectedNodeIdsRef = useRef>(new Set()); const hoveredLegendLabelRef = useRef(null); // Theme ref for canvas rendering (paintNode/paintLink read from this) const themeRef = useRef(theme); // Marquee selection state const [marquee, setMarquee] = useState<{ startX: number; // screen px startY: number; // screen px currentX: number; currentY: number; active: boolean; } | null>(null); const marqueeRef = useRef(null); // Double-tap detection for mobile context menu const lastTapRef = useRef<{ nodeId: string; time: number } | null>(null); const tapTimeoutRef = useRef | null>(null); // Cleanup tap timeout on unmount useEffect(() => { return () => { if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current); }; }, []); // Minimap dimensions (resizable via drag) const [minimapSize, setMinimapSize] = useState({ w: 160, h: 120 }); const MINIMAP_W = minimapSize.w; const MINIMAP_H = minimapSize.h; const MINIMAP_PAD = 8; // internal padding so dots aren't clipped at edges // Minimap resize drag state const minimapDragRef = useRef<{ edge: "top" | "right" | "top-right"; startX: number; startY: number; startW: number; startH: number; } | null>(null); // Lazy-load ForceGraph2D on the client (preserves ref forwarding) const [ForceGraph2D, setForceGraph2D] = useState | null>(_ForceGraph2DModule); useEffect(() => { if (_ForceGraph2DModule) return; // already loaded import("react-force-graph-2d").then((mod) => { _ForceGraph2DModule = mod.default || mod; setForceGraph2D(() => _ForceGraph2DModule); }); }, []); // Layout mode: "force" (physics-based) or "dag" (topological top-down) const [layoutMode, setLayoutMode] = useState("dag"); // Whether to show hierarchical cluster circles/labels when zoomed out const [showClusters, setShowClusters] = useState(true); // Use refs for transient visual state to avoid re-rendering the ForceGraph // component (which causes simulation re-heat and the "jitter" on hover). const selectedNodeRef = useRef(selectedNode); const hoveredNodeRef = useRef(null); const connectedNodesRef = useRef>(new Set()); const commentedNodeIdsRef = useRef>(commentedNodeIds || new Map()); const claimedNodeAvatarsRef = useRef>( claimedNodeAvatars || new Map() ); const assigneeNodeAvatarsRef = useRef>( assigneeNodeAvatars || new Map() ); // Color mode ref for paintNode (which has [] deps) to read current color mode const colorModeRef = useRef(colorMode); // Pulse node ref: which node to show ripple animation on const pulseNodeIdRef = useRef(pulseNodeId || null); const showPulseRef = useRef(showPulse); // Callback ref for refreshing graph when avatar images finish loading const avatarRefreshRef = useRef<() => void>(() => {}); avatarRefreshRef.current = () => refreshGraph(graphRef); // Ref for avatar hover callback (avoids stale closures in mousemove handler) const onAvatarHoverRef = useRef(onAvatarHover); onAvatarHoverRef.current = onAvatarHover; // Ref for avatar click callback (avoids stale closures in click handler) const onAvatarClickRef = useRef(onAvatarClick); useEffect(() => { onAvatarClickRef.current = onAvatarClick; }, [onAvatarClick]); // Ref for selectedNodeIds change callback const onSelectedNodeIdsChangeRef = useRef(onSelectedNodeIdsChange); useEffect(() => { onSelectedNodeIdsChangeRef.current = onSelectedNodeIdsChange; }, [onSelectedNodeIdsChange]); // Track which avatar is currently hovered to avoid redundant callbacks const hoveredAvatarNodeRef = useRef(null); // Track when an avatar was clicked to prevent onNodeClick from also firing const avatarClickedAtRef = useRef(0); // Track last mouse position for passing coordinates with onNodeHover const lastMouseRef = useRef({ x: 0, y: 0 }); // Ref for current viewNodes (used by mousemove handler to respect epics view) const viewNodesRef = useRef(nodes); // Group drag refs: track starting positions of multi-selected nodes during drag const dragGroupStartRef = useRef | null>(null); const dragOriginRef = useRef<{ x: number; y: number } | null>(null); // Cluster data ref for semantic zoom: maps epic (parent) IDs to their // member node IDs so we can compute centroids and draw cluster labels // when zoomed out far enough. type ClusterInfo = { parentId: string; title: string; prefix: string; memberIds: string[]; }; const clustersRef = useRef([]); // Compute collapsed view when any epics are collapsed via collapsedEpicIds. // Builds a child->parent map from parent-child dependencies and hierarchical IDs, // then removes child nodes and remaps their links to the parent epic. // Must be declared BEFORE effects that reference viewNodes/viewLinks. const { viewNodes, viewLinks, preFilterNodes } = useMemo(() => { let currentNodes = nodes; let currentLinks = links; // === PHASE 1: Epic focus mode === // When focused on an epic, filter to only the epic's subgraph if (focusedEpicId) { // Build child->parent map const childToParent = new Map(); for (const link of currentLinks) { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (link.type === "parent-child") { childToParent.set(tgt, src); } } const nodeIdSet = new Set(currentNodes.map((n) => n.id)); for (const node of currentNodes) { if (!childToParent.has(node.id) && node.id.includes(".")) { const parentId = node.id.split(".")[0]; if (nodeIdSet.has(parentId)) { childToParent.set(node.id, parentId); } } } // Collect epic + direct children const subgraphIds = new Set(); subgraphIds.add(focusedEpicId); for (const [childId, parentId] of childToParent) { if (parentId === focusedEpicId) { subgraphIds.add(childId); } } // Add 1-hop neighbors connected via blocks/relates_to links for (const link of currentLinks) { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (link.type !== "parent-child") { if (subgraphIds.has(src) && nodeIdSet.has(tgt)) subgraphIds.add(tgt); if (subgraphIds.has(tgt) && nodeIdSet.has(src)) subgraphIds.add(src); } } currentNodes = currentNodes.filter((n) => subgraphIds.has(n.id)); currentLinks = currentLinks.filter((link) => { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; return subgraphIds.has(src) && subgraphIds.has(tgt); }); } // === PHASE 2: Collapse mode === if (collapsedEpicIds && collapsedEpicIds.size > 0) { // Build child->parent map from parent-child links const childToParent = new Map(); for (const link of currentLinks) { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (link.type === "parent-child") { // source is parent, target is child childToParent.set(tgt, src); } } // Fallback: infer from hierarchical IDs (e.g., "myproject-3r3.1" -> parent "myproject-3r3") const nodeIds = new Set(currentNodes.map((n) => n.id)); for (const node of currentNodes) { if (!childToParent.has(node.id) && node.id.includes(".")) { const parentId = node.id.split(".")[0]; if (nodeIds.has(parentId)) { childToParent.set(node.id, parentId); } } } // Collapse children whose parent is in collapsedEpicIds const childIds = new Set(); for (const [childId, parentId] of childToParent) { if (collapsedEpicIds.has(parentId)) { childIds.add(childId); } } // Only run collapse transform if there are children to collapse if (childIds.size > 0) { // Also build a filtered childToParent for only the collapsed children (for link remapping) const collapsedChildToParent = new Map(); for (const childId of childIds) { collapsedChildToParent.set(childId, childToParent.get(childId)!); } // Accumulate collapsed children count and extra connections onto parent nodes const collapsedCounts = new Map(); const extraBlockerCount = new Map(); const extraDependentCount = new Map(); for (const [childId, parentId] of collapsedChildToParent) { collapsedCounts.set(parentId, (collapsedCounts.get(parentId) || 0) + 1); const child = currentNodes.find((n) => n.id === childId); if (child) { extraBlockerCount.set(parentId, (extraBlockerCount.get(parentId) || 0) + child.blockerCount); extraDependentCount.set(parentId, (extraDependentCount.get(parentId) || 0) + child.dependentCount); } } // Filter nodes: remove collapsed children, augment their parents const filteredNodes: GraphNode[] = currentNodes .filter((n) => !childIds.has(n.id)) .map((n) => ({ ...n, blockerCount: n.blockerCount + (extraBlockerCount.get(n.id) || 0), dependentCount: n.dependentCount + (extraDependentCount.get(n.id) || 0), collapsedCount: collapsedCounts.get(n.id) || 0, })); // Remap links: replace collapsed child IDs with parent IDs, drop internal parent-child links const remappedLinks: GraphLink[] = []; const linkSeen = new Set(); for (const link of currentLinks) { let src = typeof link.source === "object" ? (link.source as any).id : link.source; let tgt = typeof link.target === "object" ? (link.target as any).id : link.target; // Drop parent-child links where the child is collapsed if (link.type === "parent-child" && childIds.has(tgt)) continue; // Remap collapsed child endpoints to their parent src = collapsedChildToParent.get(src) || src; tgt = collapsedChildToParent.get(tgt) || tgt; if (src === tgt) continue; // self-link after collapse const key = `${src}->${tgt}:${link.type}`; if (linkSeen.has(key)) continue; linkSeen.add(key); remappedLinks.push({ source: src, target: tgt, type: link.type }); } // Assign back to currentNodes/currentLinks for Phase 3 currentNodes = filteredNodes; currentLinks = remappedLinks; } } // Save pre-filter snapshot (after Phase 1+2, before Phase 3) const preFilterNodes = [...currentNodes]; // === PHASE 3: Legend filter === if (activeLegendFilters && activeLegendFilters.size > 0) { const matchesFilter = (node: GraphNode): boolean => { switch (colorMode) { case "status": return activeLegendFilters.has(node.status); case "priority": return activeLegendFilters.has(String(node.priority)); case "type": return activeLegendFilters.has(node.issueType); case "owner": return activeLegendFilters.has(node.createdBy || "Unassigned"); case "assignee": return activeLegendFilters.has(node.assignee || "Unassigned"); case "prefix": return activeLegendFilters.has(getPrefixLabel(node.prefix)); default: return true; } }; const filteredIds = new Set(currentNodes.filter(matchesFilter).map(n => n.id)); currentNodes = currentNodes.filter(n => filteredIds.has(n.id)); currentLinks = currentLinks.filter(link => { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; return filteredIds.has(src) && filteredIds.has(tgt); }); } return { viewNodes: currentNodes, viewLinks: currentLinks, preFilterNodes }; }, [nodes, links, collapsedEpicIds, focusedEpicId, activeLegendFilters, colorMode]); // Keep viewNodesRef in sync for mousemove avatar hit-testing viewNodesRef.current = viewNodes; // Compute filtered stats when legend filters are active const filteredStats = useMemo(() => { if (!stats || !activeLegendFilters || activeLegendFilters.size === 0) return stats; return { total: viewNodes.length, edges: viewLinks.length, prefixes: [...new Set(viewNodes.map(n => n.prefix))], }; }, [stats, activeLegendFilters, viewNodes, viewLinks]); // Build cluster info for semantic zoom: group nodes by parent epic. // This is used by onRenderFramePost to draw cluster labels when zoomed out. useEffect(() => { // Build child→parent map (same logic as epics useMemo above) const childToParent = new Map(); for (const link of links) { const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (link.type === "parent-child") { childToParent.set(tgt, src); } } const nodeIds = new Set(nodes.map((n) => n.id)); for (const node of nodes) { if (!childToParent.has(node.id) && node.id.includes(".")) { const parentId = node.id.split(".")[0]; if (nodeIds.has(parentId)) { childToParent.set(node.id, parentId); } } } // Group children under parents const parentToChildren = new Map(); for (const [childId, parentId] of childToParent) { const arr = parentToChildren.get(parentId) || []; arr.push(childId); parentToChildren.set(parentId, arr); } const nodeMap = new Map(nodes.map((n) => [n.id, n])); const clusters: ClusterInfo[] = []; // Only epic clusters: parent + its children (skip standalone/disconnected nodes) for (const [parentId, childIds] of parentToChildren) { const parent = nodeMap.get(parentId); if (!parent) continue; clusters.push({ parentId, title: parent.title || parentId, prefix: parent.prefix, memberIds: [parentId, ...childIds], }); } clustersRef.current = clusters; }, [nodes, links]); // Compute dynamic legend items based on color mode and visible nodes const legendItems = useMemo(() => { if (colorMode === "status" || colorMode === "priority" || colorMode === "type") return []; // handled by static rendering const items = new Map(); // label -> color for (const node of preFilterNodes) { switch (colorMode) { case "owner": { const key = node.createdBy || undefined; items.set(key || "Unassigned", getPersonColor(key)); break; } case "assignee": { const key = node.assignee || undefined; items.set(key || "Unassigned", getPersonColor(key)); break; } case "prefix": { items.set(getPrefixLabel(node.prefix), getCatppuccinPrefixColor(node.prefix)); break; } } } // Sort: "Unassigned" last, others alphabetically return Array.from(items.entries()) .sort(([a], [b]) => { if (a === "Unassigned") return 1; if (b === "Unassigned") return -1; return a.localeCompare(b); }) .map(([label, color]) => ({ label, color })); }, [colorMode, preFilterNodes]); // Ref for viewLinks to access current value in hover handler without adding as dependency const viewLinksRef = useRef(viewLinks); useEffect(() => { viewLinksRef.current = viewLinks; }, [viewLinks]); // Sync props into refs and trigger canvas redraw (not React re-render). // Also schedules a minimap redraw so highlight state is synced there too. // Uses viewLinks (respects epic collapse) for connected subgraph computation. useEffect(() => { selectedNodeRef.current = selectedNode; // Recompute connected subgraph const target = hoveredNodeRef.current || selectedNode; if (target) { connectedNodesRef.current = computeConnectedNodes(target.id, viewLinks); } else { connectedNodesRef.current = new Set(); } refreshGraph(graphRef); // Minimap picks up highlight from refs — schedule redraw cancelAnimationFrame(minimapRafRef.current); minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current()); }, [selectedNode, viewLinks]); useEffect(() => { selectedNodeIdsRef.current = selectedNodeIds || new Set(); refreshGraph(graphRef); cancelAnimationFrame(minimapRafRef.current); minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current()); }, [selectedNodeIds]); // Sync commentedNodeIds ref and trigger canvas redraw useEffect(() => { commentedNodeIdsRef.current = commentedNodeIds || new Map(); refreshGraph(graphRef); }, [commentedNodeIds]); useEffect(() => { claimedNodeAvatarsRef.current = claimedNodeAvatars || new Map(); refreshGraph(graphRef); }, [claimedNodeAvatars]); useEffect(() => { assigneeNodeAvatarsRef.current = assigneeNodeAvatars || new Map(); refreshGraph(graphRef); }, [assigneeNodeAvatars]); // Sync color mode to module-level variable and ref, trigger canvas + minimap redraw useEffect(() => { colorModeRef.current = colorMode; _currentColorMode = colorMode; refreshGraph(graphRef); minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current()); }, [colorMode]); // Sync pulse node ref useEffect(() => { pulseNodeIdRef.current = pulseNodeId || null; showPulseRef.current = showPulse; refreshGraph(graphRef); }, [pulseNodeId, showPulse]); // Sync theme ref and trigger canvas + minimap redraw useEffect(() => { themeRef.current = theme; refreshGraph(graphRef); minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current()); }, [theme]); // Sync marquee ref useEffect(() => { marqueeRef.current = marquee; }, [marquee]); // Register event listeners once when marquee starts useEffect(() => { if (!marquee) return; const handleMove = (e: PointerEvent) => { const m = marqueeRef.current; if (!m) return; const dx = e.clientX - m.startX; const dy = e.clientY - m.startY; const active = m.active || Math.abs(dx) > 5 || Math.abs(dy) > 5; setMarquee(prev => prev ? { ...prev, currentX: e.clientX, currentY: e.clientY, active } : null); }; const handleUp = (e: PointerEvent) => { const m = marqueeRef.current; if (!m) { setMarquee(null); return; } if (m.active) { // Convert marquee screen rect to graph coords const fg = graphRef.current; if (fg) { const rect = containerRef.current?.getBoundingClientRect(); if (rect) { const tl = fg.screen2GraphCoords( Math.min(m.startX, e.clientX) - rect.left, Math.min(m.startY, e.clientY) - rect.top ); const br = fg.screen2GraphCoords( Math.max(m.startX, e.clientX) - rect.left, Math.max(m.startY, e.clientY) - rect.top ); // Find all nodes inside the rectangle const selected = new Set(); for (const node of viewNodesRef.current) { const n = node as any; if (n.x != null && n.y != null) { if (n.x >= tl.x && n.x <= br.x && n.y >= tl.y && n.y <= br.y) { selected.add(node.id); } } } if (selected.size > 0) { onSelectedNodeIdsChangeRef.current?.(selected); } } } } setMarquee(null); }; window.addEventListener("pointermove", handleMove); window.addEventListener("pointerup", handleUp); return () => { window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointerup", handleUp); }; }, [marquee ? true : false]); // Only re-run when marquee transitions null <-> non-null // Avatar hover detection: mousemove on container, hit-test against avatar positions useEffect(() => { const container = containerRef.current; if (!container) return; const handleMouseMove = (e: MouseEvent) => { // Track last mouse position for onNodeHover coordinates lastMouseRef.current = { x: e.clientX, y: e.clientY }; const fg = graphRef.current; const cb = onAvatarHoverRef.current; if (!fg || !cb) return; const claimedMap = claimedNodeAvatarsRef.current; if (claimedMap.size === 0 && assigneeNodeAvatarsRef.current.size === 0) { if (hoveredAvatarNodeRef.current) { hoveredAvatarNodeRef.current = null; cb(null); } return; } // Convert screen coords to graph coords const rect = container.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; let graphCoords: { x: number; y: number }; try { graphCoords = fg.screen2GraphCoords(screenX, screenY); } catch { return; } // Hit-test against each claimed node's avatar position const globalScale = fg.zoom() || 1; const avatarRadius = Math.max(4, 10 / globalScale); for (const node of viewNodesRef.current) { const n = node as any; if (n.x == null || n.y == null) continue; const claim = claimedMap.get(node.id); if (!claim) continue; const size = getNodeSize(node); const avatarX = n.x + size * 0.7; const avatarY = n.y + size * 0.7; const dx = graphCoords.x - avatarX; const dy = graphCoords.y - avatarY; if (dx * dx + dy * dy <= avatarRadius * avatarRadius) { if (hoveredAvatarNodeRef.current !== node.id) { hoveredAvatarNodeRef.current = node.id; cb({ handle: claim.handle, avatar: claim.avatar, claimedAt: claim.claimedAt, did: claim.did, x: e.clientX, y: e.clientY, nodeId: node.id, nodeClosed: node.status === "closed" }); } container.style.cursor = "pointer"; return; } } // Hit-test assignee avatars (only nodes without claim avatars) const assigneeMap = assigneeNodeAvatarsRef.current; if (assigneeMap.size > 0) { for (const node of viewNodesRef.current) { const n = node as any; if (n.x == null || n.y == null) continue; if (claimedMap.get(node.id)) continue; // skip claimed nodes const assignee = assigneeMap.get(node.id); if (!assignee) continue; const size = getNodeSize(node); const avatarX = n.x + size * 0.7; const avatarY = n.y + size * 0.7; const dx = graphCoords.x - avatarX; const dy = graphCoords.y - avatarY; if (dx * dx + dy * dy <= avatarRadius * avatarRadius) { if (hoveredAvatarNodeRef.current !== node.id) { hoveredAvatarNodeRef.current = node.id; cb({ handle: assignee.handle, avatar: assignee.avatar, claimedAt: assignee.updatedAt, did: undefined, x: e.clientX, y: e.clientY, isAssignee: true, nodeId: node.id, nodeClosed: node.status === "closed", }); } container.style.cursor = "pointer"; return; } } } // No avatar hit if (hoveredAvatarNodeRef.current) { hoveredAvatarNodeRef.current = null; cb(null); } container.style.cursor = ""; }; const handleClick = (e: MouseEvent) => { const fg = graphRef.current; const cb = onAvatarClickRef.current; if (!fg || !cb) return; const claimedMap = claimedNodeAvatarsRef.current; const assigneeMap = assigneeNodeAvatarsRef.current; if ((!claimedMap || claimedMap.size === 0) && (!assigneeMap || assigneeMap.size === 0)) return; const rect = container.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; const { x: gx, y: gy } = fg.screen2GraphCoords(screenX, screenY); const globalScale = fg.zoom() || 1; const avatarRadius = Math.max(4, 10 / globalScale); // Check claimed avatars first if (claimedMap) { for (const node of viewNodesRef.current) { const n = node as any; if (n.x == null || n.y == null) continue; const claim = claimedMap.get(node.id); if (!claim) continue; const size = getNodeSize(node); const avatarX = n.x + size * 0.7; const avatarY = n.y + size * 0.7; const dx = gx - avatarX; const dy = gy - avatarY; if (dx * dx + dy * dy <= avatarRadius * avatarRadius) { // Defense-in-depth: stopPropagation doesn't prevent force-graph's internal // canvas hit-testing from firing onNodeClick. The avatarClickedAtRef timestamp // (checked in handleNodeClickWithDoubleTap) is the actual guard. e.stopPropagation(); avatarClickedAtRef.current = Date.now(); cb({ handle: claim.handle, avatar: claim.avatar, did: claim.did, nodeId: node.id }); return; } } } // Check assignee avatars (skip nodes with claims) if (assigneeMap) { for (const node of viewNodesRef.current) { const n = node as any; if (n.x == null || n.y == null) continue; if (claimedMap?.has(node.id)) continue; const assignee = assigneeMap.get(node.id); if (!assignee) continue; const size = getNodeSize(node); const avatarX = n.x + size * 0.7; const avatarY = n.y + size * 0.7; const dx = gx - avatarX; const dy = gy - avatarY; if (dx * dx + dy * dy <= avatarRadius * avatarRadius) { // Defense-in-depth: stopPropagation doesn't prevent force-graph's internal // canvas hit-testing from firing onNodeClick. The avatarClickedAtRef timestamp // (checked in handleNodeClickWithDoubleTap) is the actual guard. e.stopPropagation(); avatarClickedAtRef.current = Date.now(); cb({ handle: assignee.handle, avatar: assignee.avatar, did: undefined, nodeId: node.id, isAssignee: true }); return; } } } }; container.addEventListener("mousemove", handleMouseMove); container.addEventListener("click", handleClick); return () => { container.removeEventListener("mousemove", handleMouseMove); container.removeEventListener("click", handleClick); }; }, []); // Track dimensions useEffect(() => { const updateDimensions = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setDimensions({ width: rect.width, height: rect.height }); } }; updateDimensions(); window.addEventListener("resize", updateDimensions); return () => window.removeEventListener("resize", updateDimensions); }, []); // Escape key clears multi-selection useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && selectedNodeIdsRef.current.size > 0) { onSelectedNodeIdsChangeRef.current?.(new Set()); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); // Focus the node: animate centerAt + zoom, then select it const focusNode = useCallback( (node: GraphNode) => { const fg = graphRef.current; if (!fg) return; // The force simulation mutates node objects in-place, so the nodes // prop array already has x/y coordinates set by the simulation. // Note: graphRef.current.graphData() is NOT available - the React // wrapper only exposes specific methods (centerAt, zoom, etc). const graphNode = viewNodes.find((n) => n.id === node.id); if (!graphNode || graphNode.x === undefined || graphNode.y === undefined) return; // Animate: center on node then zoom in fg.centerAt(graphNode.x, graphNode.y, 500); fg.zoom(2.5, 500); // Select the node (triggers highlight via parent) onNodeClick(node); }, [onNodeClick, viewNodes] ); // Zoom to node without selecting it (for profile panel navigation) const zoomToNode = useCallback( (nodeId: string) => { const fg = graphRef.current; if (!fg) return; const graphNode = viewNodes.find((n) => n.id === nodeId); if (!graphNode || graphNode.x === undefined || graphNode.y === undefined) return; fg.centerAt(graphNode.x, graphNode.y, 500); fg.zoom(2.5, 500); }, [viewNodes] ); // Expose focusNode to parent via ref useImperativeHandle(ref, () => ({ focusNode, zoomToNode }), [focusNode, zoomToNode]); // ForceGraph2D event handlers (extracted from inline lambdas for stable references) const handleForceGraphNodeHover = useCallback((node: any) => { const graphNode = node ? (node as GraphNode) : null; hoveredNodeRef.current = graphNode; // Recompute connected subgraph const target = graphNode || selectedNodeRef.current; if (target) { connectedNodesRef.current = computeConnectedNodes(target.id, viewLinksRef.current); } else { connectedNodesRef.current = new Set(); } refreshGraph(graphRef); cancelAnimationFrame(minimapRafRef.current); minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current()); // Still call the external callback for tooltip rendering onNodeHover(graphNode, lastMouseRef.current.x, lastMouseRef.current.y); }, [onNodeHover]); const handleForceGraphNodeRightClick = useCallback((node: any, event: MouseEvent) => { event.preventDefault(); onNodeRightClick?.(node as GraphNode, event); }, [onNodeRightClick]); // Single unified effect for force configuration. // Runs on initial load AND when switching between Force / DAG layouts, // so the initial graph looks the same as toggling DAG → Force. useEffect(() => { const fg = graphRef.current; if (!fg || viewNodes.length === 0) return; // Helper: clear custom forces that only specific layouts use. // Must be called at the start of every branch to prevent stale forces. const clearCustomForces = () => { fg.d3Force("radial", null); fg.d3Force("x", null); fg.d3Force("y", null); }; // Helper: clear fixed positions left over from DAG mode. const clearFixedPositions = () => { viewNodes.forEach((node: any) => { delete node.fx; delete node.fy; }); }; if (layoutMode === "dag") { // DAG mode: topological layers (td) + spread-like horizontal spacing. // Strong charge repulsion pushes siblings apart within each layer, // while dagMode handles vertical ordering. clearCustomForces(); fg.d3Force("charge")?.strength(-250).distanceMax(500); fg.d3Force("link")?.distance(120).strength(0.3); fg.d3Force("center")?.strength(0.015); // Collision prevents overlap within layers fg.d3Force("collision", forceCollide() .radius((node: any) => getNodeSize(node as GraphNode) + 8) .strength(0.8) ); } else if (layoutMode === "radial") { // Radial layout: concentric rings by dependency depth. // Compute BFS depth from root nodes (no incoming blocks edges). clearCustomForces(); clearFixedPositions(); const incoming = new Map(); for (const link of viewLinks) { if (link.type === "parent-child") continue; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; const src = typeof link.source === "object" ? (link.source as any).id : link.source; if (!incoming.has(tgt)) incoming.set(tgt, []); incoming.get(tgt)!.push(src); } const depthMap = new Map(); const queue: string[] = []; viewNodes.forEach((n: any) => { if (!incoming.has(n.id)) { depthMap.set(n.id, 0); queue.push(n.id); } }); let qi = 0; while (qi < queue.length) { const id = queue[qi++]; const d = depthMap.get(id)!; for (const link of viewLinks) { if (link.type === "parent-child") continue; const src = typeof link.source === "object" ? (link.source as any).id : link.source; const tgt = typeof link.target === "object" ? (link.target as any).id : link.target; if (src === id && !depthMap.has(tgt)) { depthMap.set(tgt, d + 1); queue.push(tgt); } } } // Store depth transiently on each node for the radial force accessor viewNodes.forEach((n: any) => { n._depth = depthMap.get(n.id) ?? 0; }); // Scale ring spacing by node count so rings don't overlap const maxDepth = Math.max(1, ...Array.from(depthMap.values())); const ringSpacing = Math.max(200, viewNodes.length * 4); fg.d3Force("charge")?.strength(-300).distanceMax(800); fg.d3Force("link")?.distance(150).strength(0.15); fg.d3Force("center")?.strength(0); // no center pull — radial handles centering fg.d3Force("radial", forceRadial( (node: any) => ((node as any)._depth || 0) * ringSpacing, 0, 0 ).strength(0.8) ); fg.d3Force("x", null); // let radial + charge handle positioning fg.d3Force("y", null); fg.d3Force("collision", forceCollide() .radius((node: any) => getNodeSize(node as GraphNode) + 10) .strength(0.9) ); } else if (layoutMode === "cluster") { // Cluster layout: group nodes by project prefix. clearCustomForces(); clearFixedPositions(); const prefixes = [...new Set(viewNodes.map((n: any) => (n as GraphNode).prefix))]; // Scale cluster separation by total node count — more nodes need more space const radius = Math.max(400, viewNodes.length * 5, prefixes.length * 150); const prefixCenters = new Map(); prefixes.forEach((prefix, i) => { const angle = (2 * Math.PI * i) / prefixes.length - Math.PI / 2; prefixCenters.set(prefix, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, }); }); fg.d3Force("charge")?.strength(-200).distanceMax(600); fg.d3Force("link")?.distance(100).strength(0.15); fg.d3Force("center")?.strength(0); // no center pull — x/y handle positioning fg.d3Force("x", forceX((node: any) => prefixCenters.get((node as GraphNode).prefix)?.x || 0).strength(0.5) ); fg.d3Force("y", forceY((node: any) => prefixCenters.get((node as GraphNode).prefix)?.y || 0).strength(0.5) ); fg.d3Force("collision", forceCollide() .radius((node: any) => getNodeSize(node as GraphNode) + 10) .strength(0.9) ); } else if (layoutMode === "spread") { // Spread layout: like force but maximally spaced for readability. clearCustomForces(); clearFixedPositions(); fg.d3Force("charge")?.strength(-300).distanceMax(500); fg.d3Force("link")?.distance(180).strength(0.4); fg.d3Force("center")?.strength(0.02); fg.d3Force("collision", forceCollide() .radius((node: any) => getNodeSize(node as GraphNode) + 8) .strength(0.8) ); } else { // Force mode: full physics (default) clearCustomForces(); clearFixedPositions(); fg.d3Force("charge")?.strength(-180).distanceMax(400); fg.d3Force("link") ?.distance((link: any) => { const srcConnections = (link.source?.blockerCount || 0) + (link.source?.dependentCount || 0); const tgtConnections = (link.target?.blockerCount || 0) + (link.target?.dependentCount || 0); const avgConnections = (srcConnections + tgtConnections) / 2; return avgConnections > 4 ? 90 : 120; }) .strength(0.6); fg.d3Force("center")?.strength(0.03); fg.d3Force("collision", forceCollide() .radius((node: any) => getNodeSize(node as GraphNode) + 6) .strength(0.7) ); } // Re-heat simulation so new forces take effect immediately fg.d3ReheatSimulation(); // Fit to view after layout settles (only if auto-fit is enabled) let timer: ReturnType | undefined; if (autoFit) { const delay = initialLayoutApplied.current ? 600 : 1000; timer = setTimeout(() => { if (graphRef.current) graphRef.current.zoomToFit(400, 60); }, delay); } initialLayoutApplied.current = true; return () => { if (timer) clearTimeout(timer); }; }, [layoutMode, viewNodes, viewLinks, autoFit]); // Bootstrap trick: start in DAG to spread nodes into good positions, // then auto-switch to Force mode. This replicates the exact code path // that makes DAG → Force look great (nodes inherit spread-out positions). const bootstrapped = useRef(false); useEffect(() => { if (bootstrapped.current || !ForceGraph2D || nodes.length === 0) return; bootstrapped.current = true; // Near-instant switch — just enough for DAG to assign positions const timer = setTimeout(() => { setLayoutMode("force"); }, 15); return () => clearTimeout(timer); }, [ForceGraph2D, nodes.length]); // Fit to view on initial load (skip during timeline replay or when auto-fit disabled) useEffect(() => { if (timelineActive) return; if (!autoFit) return; if (graphRef.current && nodes.length > 0) { const timer = setTimeout(() => { graphRef.current.zoomToFit(400, 60); }, 800); return () => clearTimeout(timer); } }, [nodes.length, timelineActive, autoFit]); // Auto zoom-to-fit when entering/exiting epic focus mode (unconditional) const prevFocusedEpicIdRef = useRef(undefined); useEffect(() => { // Skip on mount (initial render) if (prevFocusedEpicIdRef.current === undefined) { prevFocusedEpicIdRef.current = focusedEpicId ?? null; return; } prevFocusedEpicIdRef.current = focusedEpicId ?? null; const graph = graphRef.current; if (!graph) return; // Small delay to let the force graph process the new node set const timer = setTimeout(() => { graph.zoomToFit(400, 60); }, 100); return () => clearTimeout(timer); }, [focusedEpicId]); // Memoize graphData so the object reference stays stable across renders. // This prevents react-force-graph from treating it as "new data" and // re-heating the simulation on every hover/selection change. // // Pre-spread nodes that have no positions yet (initial load). // D3's default initializeNodes() places all nodes within ~44px of origin // using a tiny phyllotaxis spiral (initialRadius=10), which causes the // "squished" initial layout. We use the same golden-angle spiral but with // a much wider radius so nodes start well-distributed — matching what // happens naturally after a DAG→Force toggle. const graphData = useMemo(() => { const SPREAD = 300; const sqrtN = Math.sqrt(viewNodes.length) || 1; viewNodes.forEach((node: any, i: number) => { if (node.x == null && node.y == null) { const angle = i * Math.PI * (3 - Math.sqrt(5)); // golden angle const r = (SPREAD * Math.sqrt(0.5 + i)) / sqrtN; node.x = r * Math.cos(angle); node.y = r * Math.sin(angle); } }); return { nodes: viewNodes, links: viewLinks }; }, [viewNodes, viewLinks]); // Custom node rendering - reads from refs, not props, so no dependency // on hoveredNode/selectedNode (which would cause useCallback to recreate // the function, which would cause ForceGraph to re-render). // Helper function for dark mode detection (reads from themeRef) const isDark = () => themeRef.current === "dark"; const paintNode = useCallback( (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const graphNode = node as GraphNode; const size = getNodeSize(graphNode); const color = getNodeColor(graphNode); const prefixColor = getPrefixRingColor(graphNode); const isSelected = selectedNodeRef.current?.id === graphNode.id; const isHovered = hoveredNodeRef.current?.id === graphNode.id; const isMultiSelected = selectedNodeIdsRef.current.has(graphNode.id); const connected = connectedNodesRef.current; const isConnected = connected.has(graphNode.id); const hasHighlight = connected.size > 0; const now = Date.now(); // --- Spawn animation (pop-in) --- let spawnScale = 1; const spawnTime = graphNode._spawnTime; if (spawnTime) { const elapsed = now - spawnTime; if (elapsed < SPAWN_DURATION) { spawnScale = easeOutBack(elapsed / SPAWN_DURATION); } } // --- Remove animation (shrink-out) --- let removeScale = 1; let removeOpacity = 1; const removeTime = graphNode._removeTime; if (removeTime) { const elapsed = now - removeTime; if (elapsed < REMOVE_DURATION) { const progress = elapsed / REMOVE_DURATION; removeScale = 1 - easeOutQuad(progress); removeOpacity = 1 - progress; } else { removeScale = 0; removeOpacity = 0; } } const animScale = spawnScale * removeScale; if (animScale <= 0.01) return; // skip drawing invisible nodes const animatedSize = size * animScale; // Opacity: dim non-connected nodes when highlighting // Dim non-selected nodes when multi-selection is active const hasMultiSelection = selectedNodeIdsRef.current.size > 0; const opacity = (hasMultiSelection && !isMultiSelected ? 0.25 : hasHighlight && !isConnected ? 0.15 : graphNode.status === "closed" ? 0.5 : 1) * removeOpacity; if (opacity <= 0.01) return; // skip fully faded nodes ctx.save(); ctx.globalAlpha = opacity; // Glow for connected/selected/hovered nodes if ((isConnected && hasHighlight) || isMultiSelected) { ctx.shadowColor = "#10b981"; ctx.shadowBlur = isSelected ? 20 : isMultiSelected ? 14 : isHovered ? 16 : 10; } // Prefix ring (outer ring showing project) if (globalScale > 0.3) { ctx.beginPath(); ctx.arc(node.x, node.y, animatedSize + 2, 0, Math.PI * 2); ctx.strokeStyle = prefixColor; ctx.lineWidth = 2; ctx.stroke(); } // Node body ctx.beginPath(); ctx.arc(node.x, node.y, animatedSize, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); // Border ctx.strokeStyle = isSelected ? "#10b981" : isMultiSelected ? "#34d399" // emerald-400 for multi-selected : isHovered ? "#3f3f46" : "#e4e4e7"; ctx.lineWidth = isSelected ? 2.5 : isMultiSelected ? 2 : isHovered ? 2 : 1; ctx.stroke(); // Reset shadow ctx.shadowBlur = 0; // --- Status change ripple animation --- const changedAt = graphNode._changedAt; if (changedAt) { const elapsed = now - changedAt; if (elapsed < CHANGE_DURATION) { const progress = elapsed / CHANGE_DURATION; const rippleRadius = animatedSize + 4 + progress * 20; const rippleOpacity = (1 - progress) * 0.6; const newStatusColor = STATUS_COLORS[graphNode.status] || "#a1a1aa"; ctx.beginPath(); ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2); ctx.strokeStyle = newStatusColor; ctx.lineWidth = 2 * (1 - progress); ctx.globalAlpha = rippleOpacity; ctx.stroke(); ctx.globalAlpha = opacity; // reset } } // --- Activity pulse ripple (continuous, on most-recently-active node) --- if (showPulseRef.current && pulseNodeIdRef.current === graphNode.id) { const RIPPLE_PERIOD = 2000; // full cycle in ms const RIPPLE_COUNT = 3; const RIPPLE_STAGGER = 500; // ms between each ring // Scale ripple to ~30 screen pixels regardless of zoom level const maxExpand = Math.max(25, 30 / globalScale); const MAX_RIPPLE_RADIUS = animatedSize + maxExpand; for (let i = 0; i < RIPPLE_COUNT; i++) { const phase = ((now + i * RIPPLE_STAGGER) % RIPPLE_PERIOD) / RIPPLE_PERIOD; const rippleRadius = animatedSize + 2 / globalScale + phase * (MAX_RIPPLE_RADIUS - animatedSize); const rippleOpacity = (1 - phase) * 0.6; ctx.beginPath(); ctx.arc(node.x, node.y, rippleRadius, 0, Math.PI * 2); ctx.strokeStyle = "#10b981"; // emerald-500 ctx.lineWidth = Math.max(1.5 / globalScale, 0.5); ctx.globalAlpha = rippleOpacity * opacity; ctx.stroke(); } ctx.globalAlpha = opacity; // reset } // --- Spawn glow --- if (spawnTime) { const elapsed = now - spawnTime; if (elapsed < SPAWN_DURATION) { const glowProgress = elapsed / SPAWN_DURATION; const glowOpacity = (1 - glowProgress) * 0.4; const glowRadius = animatedSize + 6 + glowProgress * 8; ctx.beginPath(); ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2); ctx.strokeStyle = "#10b981"; ctx.lineWidth = 3 * (1 - glowProgress); ctx.globalAlpha = glowOpacity; ctx.stroke(); ctx.globalAlpha = opacity; // reset } } // Priority indicator (flame for P0/P1) if (graphNode.priority <= 1 && globalScale > 0.5) { const emojiSize = Math.min(10, Math.max(4, 12 / globalScale)); ctx.font = `${emojiSize}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText( graphNode.priority === 0 ? "\uD83D\uDD25\uD83D\uDD25" : "\uD83D\uDD25", node.x, node.y - animatedSize - 2 ); } // Label if (globalScale > 0.5) { const fontSize = Math.min(7, Math.max(3, 10 / globalScale)); ctx.font = `500 ${fontSize}px 'Inter', system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = isDark() ? "#d4d4d8" : "#3f3f46"; // zinc-300 / zinc-700 ctx.globalAlpha = opacity * 0.85; let label = graphNode.id; if (globalScale > 1.5) { label = truncate(graphNode.title || graphNode.id, 30); } else if (globalScale > 0.9) { label = truncate(graphNode.title || graphNode.id, 18); } ctx.fillText(label, node.x, node.y + animatedSize + 3); // Collapsed child count badge (only in epics view mode) const collapsedCount = (graphNode as any).collapsedCount as number | undefined; if (collapsedCount && collapsedCount > 0) { const badgeFontSize = Math.min(5.5, Math.max(2.5, 8 / globalScale)); ctx.font = `400 ${badgeFontSize}px 'Inter', system-ui, sans-serif`; ctx.fillStyle = isDark() ? "#71717a" : "#a1a1aa"; // zinc-500 / zinc-400 ctx.fillText( `${collapsedCount} task${collapsedCount !== 1 ? "s" : ""}`, node.x, node.y + animatedSize + 3 + fontSize + 1 ); } } // Comment count badge — small filled circle with number at top-right const commentCount = commentedNodeIdsRef.current.get(graphNode.id); if (commentCount && commentCount > 0 && globalScale > 0.4) { const badgeRadius = Math.min(6, Math.max(3.5, 8 / globalScale)); const badgeX = node.x + animatedSize * 0.75; const badgeY = node.y - animatedSize * 0.75; const label = commentCount > 99 ? "99+" : String(commentCount); ctx.save(); ctx.globalAlpha = Math.min(opacity, 0.95); // Badge circle — red like WhatsApp notification counter ctx.beginPath(); ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2); ctx.fillStyle = "#ef4444"; // red-500 ctx.fill(); // Border for contrast against any background ctx.strokeStyle = isDark() ? "#18181b" : "#ffffff"; // zinc-950 / white ctx.lineWidth = Math.max(0.8, 1.2 / globalScale); ctx.stroke(); // Count text const fontSize = Math.min(7, Math.max(3, badgeRadius * 1.3)); ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#ffffff"; // white text stays white ctx.fillText(label, badgeX, badgeY + 0.3); // +0.3 for optical vertical centering ctx.restore(); } // Claimant avatar — small circular profile picture at bottom-right const claimInfo = claimedNodeAvatarsRef.current.get(graphNode.id); if (claimInfo) { // Constant screen-space size: divide by globalScale so avatar stays // roughly the same pixel size regardless of zoom level const avatarSize = Math.max(4, 10 / globalScale); const avatarX = node.x + animatedSize * 0.7; const avatarY = node.y + animatedSize * 0.7; ctx.save(); ctx.globalAlpha = 1; if (claimInfo.avatar) { const img = getAvatarImage(claimInfo.avatar, () => avatarRefreshRef.current() ); if (img) { // Clip to circle and draw image ctx.save(); ctx.beginPath(); ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2); ctx.clip(); ctx.drawImage( img, avatarX - avatarSize, avatarY - avatarSize, avatarSize * 2, avatarSize * 2 ); ctx.restore(); } else { drawAvatarFallback( ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale, isDark() ); } } else { drawAvatarFallback( ctx, avatarX, avatarY, avatarSize, claimInfo.handle, globalScale, isDark() ); } // Border ring for contrast ctx.beginPath(); ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2); ctx.strokeStyle = isDark() ? "#18181b" : "#ffffff"; // zinc-950 / white ctx.lineWidth = Math.max(0.8, 1.2 / globalScale); ctx.stroke(); ctx.restore(); } // Assignee avatar — only if no claim avatar on this node if (!claimInfo) { const assigneeInfo = assigneeNodeAvatarsRef.current.get(graphNode.id); if (assigneeInfo) { const avatarSize = Math.max(4, 10 / globalScale); const avatarX = node.x + animatedSize * 0.7; const avatarY = node.y + animatedSize * 0.7; ctx.save(); ctx.globalAlpha = 1; if (assigneeInfo.avatar) { const img = getAvatarImage(assigneeInfo.avatar, () => avatarRefreshRef.current() ); if (img) { ctx.save(); ctx.beginPath(); ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2); ctx.clip(); ctx.drawImage( img, avatarX - avatarSize, avatarY - avatarSize, avatarSize * 2, avatarSize * 2 ); ctx.restore(); } else { drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, assigneeInfo.handle, globalScale, isDark()); } } else { drawAvatarFallback(ctx, avatarX, avatarY, avatarSize, assigneeInfo.handle, globalScale, isDark()); } // Border ring ctx.beginPath(); ctx.arc(avatarX, avatarY, avatarSize, 0, Math.PI * 2); ctx.strokeStyle = isDark() ? "#18181b" : "#ffffff"; // zinc-950 / white ctx.lineWidth = Math.max(0.8, 1.2 / globalScale); ctx.stroke(); ctx.restore(); } } ctx.restore(); }, [] // No dependencies - reads from refs ); // Custom link rendering — blocks links are solid with arrowheads, // parent-child links are dashed without arrowheads const paintLink = useCallback( (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const start = link.source; const end = link.target; if (start.x === undefined || end.x === undefined) return; const now = Date.now(); // --- Spawn animation (fade-in + thickness) --- let linkSpawnAlpha = 1; let linkSpawnWidth = 1; const linkSpawnTime = link._spawnTime as number | undefined; if (linkSpawnTime) { const elapsed = now - linkSpawnTime; if (elapsed < SPAWN_DURATION) { const progress = elapsed / SPAWN_DURATION; linkSpawnAlpha = easeOutQuad(progress); linkSpawnWidth = 1 + (1 - progress) * 1.5; // starts 2.5x thick, settles to 1x } } // --- Remove animation (fade-out) --- let linkRemoveAlpha = 1; const linkRemoveTime = link._removeTime as number | undefined; if (linkRemoveTime) { const elapsed = now - linkRemoveTime; if (elapsed < REMOVE_DURATION) { linkRemoveAlpha = 1 - easeOutQuad(elapsed / REMOVE_DURATION); } else { return; // fully gone, skip drawing } } const linkAnimAlpha = linkSpawnAlpha * linkRemoveAlpha; if (linkAnimAlpha <= 0.01) return; // skip invisible links const srcId = start.id || link.source; const tgtId = end.id || link.target; const isParentChild = link.type === "parent-child"; const connected = connectedNodesRef.current; const hasHighlight = connected.size > 0; const isConnectedLink = hasHighlight && connected.has(srcId) && connected.has(tgtId); // Parent-child links are more subtle const opacity = (isParentChild ? hasHighlight ? isConnectedLink ? 0.5 : 0.05 : 0.2 : hasHighlight ? isConnectedLink ? 0.8 : 0.08 : 0.35) * linkAnimAlpha; if (opacity <= 0.01) return; // skip fully faded links ctx.save(); ctx.globalAlpha = opacity; // Color and width differ by link type if (isParentChild) { // Parent-child links const darkConnected = "#a1a1aa"; // zinc-400 const darkNonConnected = "#a1a1aa"; // zinc-400 const lightConnected = "#71717a"; // zinc-500 const lightNonConnected = "#a1a1aa"; // zinc-400 ctx.strokeStyle = isConnectedLink ? (isDark() ? darkConnected : lightConnected) : (isDark() ? darkNonConnected : lightNonConnected); ctx.lineWidth = Math.max(0.6, 1.5 / globalScale) * linkSpawnWidth; ctx.setLineDash([4, 3]); } else { // Blocks links ctx.strokeStyle = isConnectedLink ? "#10b981" : (isDark() ? "#a1a1aa" : "#d4d4d8"); // emerald-500 / zinc-400 / zinc-300 ctx.lineWidth = (isConnectedLink ? Math.max(2, 2.5 / globalScale) : Math.max(0.8, 1.2 / globalScale)) * linkSpawnWidth; } // Curved link const dx = end.x - start.x; const dy = end.y - start.y; const dist = Math.sqrt(dx * dx + dy * dy); const curvature = 0.15; const cx = (start.x + end.x) / 2 + dy * curvature; const cy = (start.y + end.y) / 2 - dx * curvature; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.quadraticCurveTo(cx, cy, end.x, end.y); ctx.stroke(); // Reset dash pattern if (isParentChild) { ctx.setLineDash([]); } // Brief bright flash for new links if (linkSpawnTime) { const elapsed = now - linkSpawnTime; if (elapsed < 300) { const flashProgress = elapsed / 300; const flashAlpha = (1 - flashProgress) * 0.5; ctx.save(); ctx.globalAlpha = flashAlpha; ctx.strokeStyle = "#10b981"; // emerald ctx.lineWidth = (isParentChild ? 3 : 4) / globalScale; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.quadraticCurveTo(cx, cy, end.x, end.y); ctx.stroke(); ctx.restore(); } } // Arrowhead — only for blocks links if (!isParentChild) { const endSize = getNodeSize(end as GraphNode); if (dist < endSize + 1) { ctx.restore(); return; } const arrowLen = Math.min(8, 6 / globalScale); const t = 1 - endSize / dist; const arrowX = start.x + t * dx; const arrowY = start.y + t * dy; const angle = Math.atan2(dy, dx); ctx.fillStyle = isConnectedLink ? "#10b981" : (isDark() ? "#a1a1aa" : "#d4d4d8"); // emerald-500 / zinc-400 / zinc-300 ctx.beginPath(); ctx.moveTo(arrowX, arrowY); ctx.lineTo( arrowX - arrowLen * Math.cos(angle - Math.PI / 7), arrowY - arrowLen * Math.sin(angle - Math.PI / 7) ); ctx.lineTo( arrowX - arrowLen * Math.cos(angle + Math.PI / 7), arrowY - arrowLen * Math.sin(angle + Math.PI / 7) ); ctx.closePath(); ctx.fill(); } ctx.restore(); }, [] // No dependencies - reads from refs ); // Semantic zoom: draw epic/cluster labels when zoomed out far. // Computes centroids from live node positions and draws titles. const paintClusterLabels = useCallback( (ctx: CanvasRenderingContext2D, globalScale: number) => { if (!showClusters) return; // Only show cluster labels when zoomed out (inverse of node fade range) const LABEL_FADE_IN = 0.8; // starts appearing const LABEL_FULL = 0.4; // fully visible const labelAlpha = globalScale >= LABEL_FADE_IN ? 0 : globalScale <= LABEL_FULL ? 1 : (LABEL_FADE_IN - globalScale) / (LABEL_FADE_IN - LABEL_FULL); if (labelAlpha <= 0.01) return; const clusters = clustersRef.current; if (clusters.length === 0) return; // Build a fast lookup from node ID to current LIVE position. // Only use viewNodes (the nodes actually in the simulation) — in epics // view, child nodes are collapsed into parent epics and their positions // are stale/frozen. Using stale positions causes centroids to drift. const nodeMap = new Map(); for (const node of viewNodes) { const n = node as any; if (n.x != null && n.y != null) { nodeMap.set(node.id, { x: n.x, y: n.y }); } } ctx.save(); for (const cluster of clusters) { // Compute centroid from member positions let sumX = 0; let sumY = 0; let count = 0; for (const id of cluster.memberIds) { const pos = nodeMap.get(id); if (pos) { sumX += pos.x; sumY += pos.y; count++; } } if (count === 0) continue; const cx = sumX / count; const cy = sumY / count; // Compute bounding radius for the subtle background circle let maxDist = 0; for (const id of cluster.memberIds) { const pos = nodeMap.get(id); if (pos) { const dx = pos.x - cx; const dy = pos.y - cy; const d = Math.sqrt(dx * dx + dy * dy); if (d > maxDist) maxDist = d; } } const radius = maxDist + 30; // padding around outermost node // Use Catppuccin prefix color for the cluster circle (clusters always represent projects) const clusterColor = getCatppuccinPrefixColor(cluster.prefix); // Draw subtle cluster background circle ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.globalAlpha = 0.05 * labelAlpha; ctx.fillStyle = clusterColor; ctx.fill(); ctx.globalAlpha = 0.25 * labelAlpha; ctx.strokeStyle = clusterColor; ctx.lineWidth = 1.5 / globalScale; ctx.setLineDash([8 / globalScale, 4 / globalScale]); ctx.stroke(); ctx.setLineDash([]); // Draw epic ID above the title const fontSize = Math.min(24, Math.max(10, 14 / globalScale)); const idFontSize = Math.min(12, Math.max(5, 8 / globalScale)); const lineGap = fontSize * 0.35; ctx.font = `500 ${idFontSize}px 'Inter', system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.globalAlpha = labelAlpha * 0.45; ctx.fillStyle = isDark() ? "#a1a1aa" : "#71717a"; // zinc-400 / zinc-500 ctx.fillText(cluster.parentId, cx, cy - fontSize * 0.5 - lineGap); // Draw epic/cluster title at centroid ctx.font = `600 ${fontSize}px 'Inter', system-ui, sans-serif`; ctx.globalAlpha = labelAlpha * 0.85; ctx.fillStyle = isDark() ? "#d4d4d8" : "#18181b"; // zinc-300 / zinc-900 // Truncate long titles const label = cluster.title.length > 40 ? cluster.title.slice(0, 39) + "\u2026" : cluster.title; ctx.fillText(label, cx, cy + fontSize * 0.15); // Subtitle: member count const subFontSize = Math.min(14, Math.max(6, 9 / globalScale)); ctx.font = `400 ${subFontSize}px 'Inter', system-ui, sans-serif`; ctx.globalAlpha = labelAlpha * 0.5; ctx.fillStyle = "#71717a"; // zinc-500 ctx.fillText( `${cluster.memberIds.length} issue${cluster.memberIds.length !== 1 ? "s" : ""}`, cx, cy + fontSize * 0.15 + fontSize * 0.7 ); } ctx.restore(); }, [viewNodes, nodes, showClusters] // reads clustersRef (ref), but needs viewNodes for positions ); // Node hit area const paintNodeArea = useCallback( (node: any, color: string, ctx: CanvasRenderingContext2D) => { const size = getNodeSize(node as GraphNode) + 5; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x, node.y, size, 0, Math.PI * 2); ctx.fill(); }, [] ); // Silently handle DAG cycle errors (some dependency graphs have cycles) const handleDagError = useCallback(() => {}, []); // ── Minimap ────────────────────────────────────────────────────────── // Redraws the minimap canvas: node dots + FOV viewport rectangle. // Uses viewNodes/viewLinks so it reflects the current view mode. const redrawMinimap = useCallback(() => { const canvas = minimapCanvasRef.current; const fg = graphRef.current; if (!canvas || !fg) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // Get world bounds from all node positions let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; let hasPositions = false; for (const node of viewNodes) { const n = node as any; if (n.x == null || n.y == null) continue; hasPositions = true; if (n.x < xMin) xMin = n.x; if (n.x > xMax) xMax = n.x; if (n.y < yMin) yMin = n.y; if (n.y > yMax) yMax = n.y; } if (!hasPositions) return; // Add margin so edge nodes aren't clipped const margin = 40; xMin -= margin; xMax += margin; yMin -= margin; yMax += margin; const worldW = xMax - xMin || 1; const worldH = yMax - yMin || 1; const drawW = MINIMAP_W - MINIMAP_PAD * 2; const drawH = MINIMAP_H - MINIMAP_PAD * 2; const scale = Math.min(drawW / worldW, drawH / worldH); const offsetX = MINIMAP_PAD + (drawW - worldW * scale) / 2; const offsetY = MINIMAP_PAD + (drawH - worldH * scale) / 2; // HiDPI support const dpr = window.devicePixelRatio || 1; if (canvas.width !== MINIMAP_W * dpr || canvas.height !== MINIMAP_H * dpr) { canvas.width = MINIMAP_W * dpr; canvas.height = MINIMAP_H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } // Clear ctx.clearRect(0, 0, MINIMAP_W, MINIMAP_H); // Background (theme-aware) ctx.fillStyle = isDark() ? "rgba(24, 24, 27, 0.92)" : "rgba(250, 250, 250, 0.92)"; // zinc-950 / zinc-50 ctx.beginPath(); ctx.roundRect(0, 0, MINIMAP_W, MINIMAP_H, 6); ctx.fill(); // Read highlight state from refs (synced with main graph) const connected = connectedNodesRef.current; const hasHighlight = connected.size > 0; const activeNodeId = hoveredNodeRef.current?.id || selectedNodeRef.current?.id || null; // Read legend hover state const hoveredLabel = hoveredLegendLabelRef.current; const hasLegendHover = hoveredLabel !== null; // Draw links for (const link of viewLinks) { const src = link.source as any; const tgt = link.target as any; if (src.x == null || tgt.x == null) continue; const srcId = src.id || link.source; const tgtId = tgt.id || link.target; const isConnectedLink = hasHighlight && connected.has(srcId) && connected.has(tgtId); ctx.globalAlpha = hasLegendHover ? 0.03 : hasHighlight ? isConnectedLink ? 0.5 : 0.04 : 0.1; ctx.strokeStyle = isConnectedLink ? "#10b981" : (isDark() ? "#71717a" : "#a1a1aa"); // emerald-500 / zinc-500 / zinc-400 ctx.lineWidth = isConnectedLink ? 1 : 0.5; const sx = offsetX + (src.x - xMin) * scale; const sy = offsetY + (src.y - yMin) * scale; const tx = offsetX + (tgt.x - xMin) * scale; const ty = offsetY + (tgt.y - yMin) * scale; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(tx, ty); ctx.stroke(); } ctx.globalAlpha = 1; // Draw nodes as tiny dots (fillRect is faster than arc) for (const node of viewNodes) { const n = node as any; if (n.x == null || n.y == null) continue; const mx = offsetX + (n.x - xMin) * scale; const my = offsetY + (n.y - yMin) * scale; const isActive = node.id === activeNodeId; const isConnected = connected.has(node.id); // Check if node matches the hovered legend label let matchesLegendHover = true; if (hasLegendHover) { const cm = _currentColorMode; switch (cm) { case "status": matchesLegendHover = node.status === hoveredLabel; break; case "priority": matchesLegendHover = String(node.priority) === hoveredLabel; break; case "type": matchesLegendHover = node.issueType === hoveredLabel; break; case "owner": matchesLegendHover = (node.createdBy || "Unassigned") === hoveredLabel; break; case "assignee": matchesLegendHover = (node.assignee || "Unassigned") === hoveredLabel; break; case "prefix": matchesLegendHover = getPrefixLabel(node.prefix) === hoveredLabel; break; } } // Opacity: legend hover takes priority, then highlight, then status if (hasLegendHover && !matchesLegendHover) { ctx.globalAlpha = 0.08; } else if (hasLegendHover && matchesLegendHover) { ctx.globalAlpha = 1.0; } else if (hasHighlight && !isConnected) { ctx.globalAlpha = 0.1; } else if (node.status === "closed") { ctx.globalAlpha = 0.35; } else { ctx.globalAlpha = 0.85; } ctx.fillStyle = getNodeColor(node); // Connected/active nodes get a bigger dot + glow let dotSize = node.issueType === "epic" ? 3 : 2; if (hasLegendHover && matchesLegendHover) { dotSize += 1; // matching nodes pop } else if (isActive) { dotSize = 5; } else if (hasHighlight && isConnected) { dotSize = 4; } // Glow ring for the active node if (isActive) { const savedAlpha = ctx.globalAlpha; ctx.globalAlpha = 0.4; ctx.fillStyle = "#10b981"; ctx.beginPath(); ctx.arc(mx, my, dotSize + 2, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = savedAlpha; ctx.fillStyle = getNodeColor(node); } ctx.fillRect(mx - dotSize / 2, my - dotSize / 2, dotSize, dotSize); } ctx.globalAlpha = 1; // Draw claimed avatars on minimap const claimedMap = claimedNodeAvatarsRef.current; if (claimedMap.size > 0) { for (const node of viewNodes) { const n = node as any; if (n.x == null || n.y == null) continue; const claim = claimedMap.get(node.id); if (!claim) continue; const mx = offsetX + (n.x - xMin) * scale; const my = offsetY + (n.y - yMin) * scale; const r = 5; // fixed pixel radius on minimap ctx.save(); ctx.globalAlpha = 1; if (claim.avatar) { const img = getAvatarImage(claim.avatar, () => avatarRefreshRef.current() ); if (img) { ctx.save(); ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, mx - r, my - r, r * 2, r * 2); ctx.restore(); } else { // Fallback circle ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.fillStyle = isDark() ? "#52525b" : "#d4d4d8"; // zinc-600 / zinc-300 ctx.fill(); } } else { ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.fillStyle = isDark() ? "#52525b" : "#d4d4d8"; // zinc-600 / zinc-300 ctx.fill(); } // Border ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.strokeStyle = isDark() ? "#18181b" : "#ffffff"; // zinc-950 / white ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } } // Draw assignee avatars on minimap (only for nodes without claim avatars) const assigneeMap = assigneeNodeAvatarsRef.current; if (assigneeMap.size > 0) { for (const node of viewNodes) { const n = node as any; if (n.x == null || n.y == null) continue; // Skip if this node already has a claim avatar (drawn above) if (claimedMap.get(node.id)) continue; const assignee = assigneeMap.get(node.id); if (!assignee) continue; const mx = offsetX + (n.x - xMin) * scale; const my = offsetY + (n.y - yMin) * scale; const r = 5; // same fixed pixel radius as claim avatars ctx.save(); ctx.globalAlpha = 1; if (assignee.avatar) { const img = getAvatarImage(assignee.avatar, () => avatarRefreshRef.current() ); if (img) { ctx.save(); ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, mx - r, my - r, r * 2, r * 2); ctx.restore(); } else { ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.fillStyle = isDark() ? "#52525b" : "#d4d4d8"; // zinc-600 / zinc-300 ctx.fill(); } } else { ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.fillStyle = isDark() ? "#52525b" : "#d4d4d8"; // zinc-600 / zinc-300 ctx.fill(); } // Border ctx.beginPath(); ctx.arc(mx, my, r, 0, Math.PI * 2); ctx.strokeStyle = isDark() ? "#18181b" : "#ffffff"; // zinc-950 / white ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } } // Draw FOV rectangle try { const tl = fg.screen2GraphCoords(0, 0); const br = fg.screen2GraphCoords(dimensions.width, dimensions.height); const rx = offsetX + (tl.x - xMin) * scale; const ry = offsetY + (tl.y - yMin) * scale; const rw = (br.x - tl.x) * scale; const rh = (br.y - tl.y) * scale; // Clamp to minimap bounds const clampX = Math.max(0, rx); const clampY = Math.max(0, ry); const clampW = Math.min(MINIMAP_W - clampX, rw - (clampX - rx)); const clampH = Math.min(MINIMAP_H - clampY, rh - (clampY - ry)); if (clampW > 0 && clampH > 0) { // Fill ctx.fillStyle = "rgba(16, 185, 129, 0.06)"; ctx.fillRect(clampX, clampY, clampW, clampH); // Border ctx.strokeStyle = "rgba(16, 185, 129, 0.5)"; ctx.lineWidth = 1.5; ctx.strokeRect(clampX, clampY, clampW, clampH); } } catch { // screen2GraphCoords can fail before graph is fully initialized } }, [viewNodes, viewLinks, dimensions, MINIMAP_W, MINIMAP_H, MINIMAP_PAD]); // Keep ref in sync so effects declared before redrawMinimap can call it redrawMinimapRef.current = redrawMinimap; // Trigger minimap redraw on every zoom/pan event const handleZoom = useCallback(() => { // Debounce with rAF to avoid redundant redraws cancelAnimationFrame(minimapRafRef.current); minimapRafRef.current = requestAnimationFrame(() => { redrawMinimap(); }); }, [redrawMinimap]); // Redraw minimap periodically during simulation (nodes move) useEffect(() => { if (!ForceGraph2D || nodes.length === 0) return; const interval = setInterval(() => { redrawMinimap(); }, 200); return () => clearInterval(interval); }, [ForceGraph2D, nodes.length, redrawMinimap]); // Drive continuous canvas redraws during active animations useEffect(() => { let rafId: number; let active = true; let running = false; function tick() { if (!active) return; const now = Date.now(); const hasActiveAnimations = viewNodes.some((n: GraphNode) => { if (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) return true; if (n._removeTime && now - n._removeTime < REMOVE_DURATION) return true; if (n._changedAt && now - n._changedAt < CHANGE_DURATION) return true; return false; }) || viewLinks.some((l: GraphLink) => { if (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) return true; if (l._removeTime && now - l._removeTime < REMOVE_DURATION) return true; return false; }); if (hasActiveAnimations) { refreshGraph(graphRef); rafId = requestAnimationFrame(tick); } else { running = false; // STOP — no more rAF until next viewNodes/viewLinks change } } // Check if any animations exist and start the loop if needed const now = Date.now(); const hasAnimations = viewNodes.some((n: GraphNode) => (n._spawnTime && now - n._spawnTime < SPAWN_DURATION) || (n._removeTime && now - n._removeTime < REMOVE_DURATION) || (n._changedAt && now - n._changedAt < CHANGE_DURATION) ) || viewLinks.some((l: GraphLink) => (l._spawnTime && now - l._spawnTime < SPAWN_DURATION) || (l._removeTime && now - l._removeTime < REMOVE_DURATION) ); if (hasAnimations && !running) { running = true; rafId = requestAnimationFrame(tick); } return () => { active = false; cancelAnimationFrame(rafId); }; }, [viewNodes, viewLinks]); // Click on minimap to navigate the main graph const handleMinimapClick = useCallback( (e: React.MouseEvent) => { const fg = graphRef.current; if (!fg) return; const rect = e.currentTarget.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; // Recompute world bounds (same logic as redrawMinimap) let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; for (const node of viewNodes) { const n = node as any; if (n.x == null || n.y == null) continue; if (n.x < xMin) xMin = n.x; if (n.x > xMax) xMax = n.x; if (n.y < yMin) yMin = n.y; if (n.y > yMax) yMax = n.y; } const margin = 40; xMin -= margin; xMax += margin; yMin -= margin; yMax += margin; const worldW = xMax - xMin || 1; const worldH = yMax - yMin || 1; const drawW = MINIMAP_W - MINIMAP_PAD * 2; const drawH = MINIMAP_H - MINIMAP_PAD * 2; const scale = Math.min(drawW / worldW, drawH / worldH); const offsetX = MINIMAP_PAD + (drawW - worldW * scale) / 2; const offsetY = MINIMAP_PAD + (drawH - worldH * scale) / 2; // Map minimap pixel → graph coordinate const graphX = xMin + (mx - offsetX) / scale; const graphY = yMin + (my - offsetY) / scale; fg.centerAt(graphX, graphY, 300); }, [viewNodes, MINIMAP_W, MINIMAP_H, MINIMAP_PAD] ); // Wrapped node click handler with double-tap detection for mobile const handleNodeClickWithDoubleTap = useCallback( (node: any, event?: MouseEvent) => { // Skip if avatar was just clicked (within 200ms) — avatar click opens ProfilePanel instead if (Date.now() - avatarClickedAtRef.current < 200) return; const graphNode = node as GraphNode; if (!isMobile) { // Desktop: immediate click, no delay onNodeClick(graphNode, event); return; } const now = Date.now(); const last = lastTapRef.current; if (last && last.nodeId === graphNode.id && now - last.time < 300) { // Double-tap detected — cancel pending single-tap if (tapTimeoutRef.current) { clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null; } lastTapRef.current = null; onNodeDoubleTap?.(graphNode, window.innerWidth / 2, window.innerHeight / 2); } else { // First tap — delay single-tap to wait for potential second tap lastTapRef.current = { nodeId: graphNode.id, time: now }; if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = setTimeout(() => { tapTimeoutRef.current = null; lastTapRef.current = null; onNodeClick(graphNode, event); }, 300); } }, [isMobile, onNodeClick, onNodeDoubleTap] ); // Attach native pointerdown listener in capture phase to intercept before react-force-graph-2d useEffect(() => { const container = containerRef.current; if (!container) return; const handlePointerDown = (e: PointerEvent) => { // Check if click is on empty canvas (not on a UI control) // Only return if we hit a UI control, not the graph container itself const closest = (e.target as HTMLElement).closest("button, [data-tutorial], .bead-tooltip"); if (closest && closest !== containerRef.current) return; // Right click only if (e.button !== 2) return; // Check if pointer is on a node const fg = graphRef.current; if (!fg) return; const rect = container.getBoundingClientRect(); const graphCoords = fg.screen2GraphCoords(e.clientX - rect.left, e.clientY - rect.top); // Check if any node is under the cursor for (const node of viewNodesRef.current) { const n = node as any; if (n.x != null && n.y != null) { const dx = n.x - graphCoords.x; const dy = n.y - graphCoords.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= getNodeSize(node)) { // Node is under cursor, let react-force-graph-2d handle it return; } } } // No node hit, start marquee e.preventDefault(); e.stopPropagation(); setMarquee({ startX: e.clientX, startY: e.clientY, currentX: e.clientX, currentY: e.clientY, active: false }); }; const handleContextMenu = (e: MouseEvent) => { // Suppress context menu if marquee is active or was just completed if (marqueeRef.current) { e.preventDefault(); } }; // Use capture phase to run before react-force-graph-2d's listeners container.addEventListener("pointerdown", handlePointerDown, { capture: true }); container.addEventListener("contextmenu", handleContextMenu); return () => { container.removeEventListener("pointerdown", handlePointerDown, { capture: true }); container.removeEventListener("contextmenu", handleContextMenu); }; }, []); // Group drag: when dragging a node that's part of a multi-selection, move all selected nodes together const handleNodeDrag = useCallback((node: any, translate: { x: number; y: number }) => { const ids = selectedNodeIdsRef.current; if (ids.size < 2 || !ids.has(node.id)) return; // On first drag call, snapshot starting positions of all selected nodes (including dragged one) if (!dragGroupStartRef.current) { const startPositions = new Map(); for (const vn of viewNodesRef.current) { const n = vn as any; if (ids.has(vn.id) && n.x != null && n.y != null) { startPositions.set(vn.id, { x: n.x, y: n.y }); } } dragGroupStartRef.current = startPositions; } // Compute how far the dragged node has moved from its start position const draggedStart = dragGroupStartRef.current.get(node.id); if (!draggedStart) return; const dx = node.x - draggedStart.x; const dy = node.y - draggedStart.y; // Apply the same offset to all OTHER selected nodes for (const [id, start] of dragGroupStartRef.current) { if (id === node.id) continue; const vn = viewNodesRef.current.find(n => n.id === id) as any; if (vn) { vn.fx = start.x + dx; vn.fy = start.y + dy; } } }, []); // Group drag end: unpin all group-dragged nodes const handleNodeDragEnd = useCallback((node: any) => { if (dragGroupStartRef.current) { // Unpin all group-dragged nodes (release fx/fy) for (const [id] of dragGroupStartRef.current) { const vn = viewNodesRef.current.find(n => n.id === id) as any; if (vn) { vn.fx = undefined; vn.fy = undefined; } } dragGroupStartRef.current = null; dragOriginRef.current = null; } }, []); return (
{/* Top-left controls */}
{/* Focus mode banner */} {focusedEpicId && onExitFocusedEpic && (
Focused: {nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}
)} {/* Row 1: Layout shape controls */}
{/* Layout mode toggle */}
{/* Row 2: View toggles */}
{/* Collapse / Expand all toggle */} {(onCollapseAll || onExpandAll) && ( <>
)} {/* Show/hide cluster labels toggle */}
{/* Auto-fit: lock/unlock automatic camera reframing */}
{/* Pulse: highlight most recently active node */}
{/* Particles: toggle flow particles on dependency links */}
{/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */} {!timelineActive && (
{filteredStats && (
{filteredStats.total} issues {" · "} {filteredStats.edges} deps {" · "} {filteredStats.prefixes.length} {filteredStats.prefixes.length === 1 ? " project" : " projects"} {activeLegendFilters && activeLegendFilters.size > 0 && ( (filtered) )}
)} {/* Color mode segmented control */}
{(["status", "priority", "type", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => ( ))}
{/* Dynamic legend: status/priority dots or person/prefix dots */}
{colorMode === "status" ? ( <> {["open", "in_progress", "blocked", "deferred", "closed"].map((status) => { const isActive = activeLegendFilters?.has(status); const anyActive = activeLegendFilters && activeLegendFilters.size > 0; return ( ); })} ) : colorMode === "priority" ? ( <> {[0, 1, 2, 3, 4].map((p) => { const key = String(p); const isActive = activeLegendFilters?.has(key); const anyActive = activeLegendFilters && activeLegendFilters.size > 0; return ( ); })} ) : colorMode === "type" ? ( <> {["epic", "task", "bug", "feature", "chore"].map((type) => { const isActive = activeLegendFilters?.has(type); const anyActive = activeLegendFilters && activeLegendFilters.size > 0; return ( ); })} ) : ( <> {legendItems.map(({ label, color }) => { const isActive = activeLegendFilters?.has(label); const anyActive = activeLegendFilters && activeLegendFilters.size > 0; return ( ); })} )} {activeLegendFilters && activeLegendFilters.size > 0 && ( )}
Size = importance · Ring = project {colorMode !== "status" && ` · Fill = ${COLOR_MODE_LABELS[colorMode].toLowerCase()}`}
Tap a node for details
)} {/* Minimap — bottom-left, hidden on mobile, resizable */}
{/* Resize handle — top edge */}
{ e.preventDefault(); e.stopPropagation(); minimapDragRef.current = { edge: "top", startX: e.clientX, startY: e.clientY, startW: MINIMAP_W, startH: MINIMAP_H, }; const onMove = (ev: MouseEvent) => { if (!minimapDragRef.current) return; const dy = minimapDragRef.current.startY - ev.clientY; const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy)); setMinimapSize((prev) => ({ ...prev, h: newH })); }; const onUp = () => { minimapDragRef.current = null; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }} /> {/* Resize handle — right edge */}
{ e.preventDefault(); e.stopPropagation(); minimapDragRef.current = { edge: "right", startX: e.clientX, startY: e.clientY, startW: MINIMAP_W, startH: MINIMAP_H, }; const onMove = (ev: MouseEvent) => { if (!minimapDragRef.current) return; const dx = ev.clientX - minimapDragRef.current.startX; const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx)); setMinimapSize((prev) => ({ ...prev, w: newW })); }; const onUp = () => { minimapDragRef.current = null; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }} /> {/* Resize handle — top-right corner */}
{ e.preventDefault(); e.stopPropagation(); minimapDragRef.current = { edge: "top-right", startX: e.clientX, startY: e.clientY, startW: MINIMAP_W, startH: MINIMAP_H, }; const onMove = (ev: MouseEvent) => { if (!minimapDragRef.current) return; const dx = ev.clientX - minimapDragRef.current.startX; const dy = minimapDragRef.current.startY - ev.clientY; const newW = Math.max(100, Math.min(500, minimapDragRef.current.startW + dx)); const newH = Math.max(80, Math.min(400, minimapDragRef.current.startH + dy)); setMinimapSize({ w: newW, h: newH }); }; const onUp = () => { minimapDragRef.current = null; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }} />
{ForceGraph2D ? ( (!showParticles || link.type === "parent-child") ? 0 : 2} linkDirectionalParticleSpeed={showParticles ? 0.004 : 0} linkDirectionalParticleWidth={showParticles ? 2.5 : 0} linkDirectionalParticleColor={PARTICLE_COLOR} // DAG mode: "td" for top-down topological layout, undefined for force dagMode={layoutMode === "dag" ? "td" : undefined} dagLevelDistance={150} onDagError={handleDagError} // Forces d3AlphaDecay={0.02} d3VelocityDecay={0.3} cooldownTicks={300} warmupTicks={50} // Interactions onNodeClick={handleNodeClickWithDoubleTap} onNodeHover={handleForceGraphNodeHover} onNodeRightClick={handleForceGraphNodeRightClick} onBackgroundClick={onBackgroundClick} // Group drag for multi-selected nodes onNodeDrag={handleNodeDrag} onNodeDragEnd={handleNodeDragEnd} // Minimap: update FOV on every zoom/pan onZoom={handleZoom} // Background backgroundColor="transparent" // Disable auto-pause when pulse is active so canvas redraws every frame autoPauseRedraw={!(showPulse && pulseNodeId)} /> ) : (
Loading graph engine...
)} {/* Marquee selection rectangle overlay */} {marquee && marquee.active && (
)}
); }); export default React.memo(BeadsGraph); function truncate(str: string, len: number): string { if (str.length <= len) return str; return str.slice(0, len - 1) + "\u2026"; }