"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<any> | 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<string>;
  /** Callback to update multi-selected node IDs */
  onSelectedNodeIdsChange?: (ids: Set<string>) => void;
  commentedNodeIds?: Map<string, number>;
  claimedNodeAvatars?: Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>;
  assigneeNodeAvatars?: Map<string, { avatar?: string; handle: string; updatedAt: string }>;
  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<string>;
  /** 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<string>;
  /** 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<string> {
  const connected = new Set<string>([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<any>) {
  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<BeadsGraphHandle, BeadsGraphProps>(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<any>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const minimapCanvasRef = useRef<HTMLCanvasElement>(null);
  const minimapRafRef = useRef<number>(0);
  const redrawMinimapRef = useRef<() => void>(() => {});
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
  const initialLayoutApplied = useRef(false);
  const selectedNodeIdsRef = useRef<Set<string>>(new Set());
  const hoveredLegendLabelRef = useRef<string | null>(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<typeof marquee>(null);

  // Double-tap detection for mobile context menu
  const lastTapRef = useRef<{ nodeId: string; time: number } | null>(null);
  const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<React.ComponentType<any> | 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<LayoutMode>("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<GraphNode | null>(selectedNode);
  const hoveredNodeRef = useRef<GraphNode | null>(null);
  const connectedNodesRef = useRef<Set<string>>(new Set());
  const commentedNodeIdsRef = useRef<Map<string, number>>(commentedNodeIds || new Map());
  const claimedNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; claimedAt: string; did?: string }>>(
    claimedNodeAvatars || new Map()
  );
  const assigneeNodeAvatarsRef = useRef<Map<string, { avatar?: string; handle: string; updatedAt: string }>>(
    assigneeNodeAvatars || new Map()
  );
  // Color mode ref for paintNode (which has [] deps) to read current color mode
  const colorModeRef = useRef<ColorMode>(colorMode);
  // Pulse node ref: which node to show ripple animation on
  const pulseNodeIdRef = useRef<string | null>(pulseNodeId || null);
  const showPulseRef = useRef<boolean>(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<string | null>(null);

  // Track when an avatar was clicked to prevent onNodeClick from also firing
  const avatarClickedAtRef = useRef<number>(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<GraphNode[]>(nodes);

  // Group drag refs: track starting positions of multi-selected nodes during drag
  const dragGroupStartRef = useRef<Map<string, { x: number; y: number }> | 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<ClusterInfo[]>([]);

  // 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<string, string>();
      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<string>();
      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<string, string>();
      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<string>();
      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<string, string>();
        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<string, number>();
        const extraBlockerCount = new Map<string, number>();
        const extraDependentCount = new Map<string, number>();
        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<string>();
        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<string, string>();
    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<string, string[]>();
    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<string, string>(); // 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<string>();
            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<string, string[]>();
      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<string, number>();
      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<string, { x: number; y: number }>();
      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<typeof setTimeout> | 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<string | null | undefined>(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<string, { x: number; y: number }>();
      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<HTMLCanvasElement>) => {
      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<string, { x: number; y: number }>();
      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 (
    <div ref={containerRef} className="w-full h-full relative" data-tutorial="graph">
      {/* Top-left controls */}
      <div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-10 flex flex-col gap-1.5 sm:gap-2">
        {/* Focus mode banner */}
        {focusedEpicId && onExitFocusedEpic && (
          <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-emerald-50/90 dark:bg-emerald-950/90 backdrop-blur-sm rounded-lg border border-emerald-200 dark:border-emerald-800 shadow-sm text-emerald-700 dark:text-emerald-300">
            <svg className="w-3.5 h-3.5 text-emerald-500 dark:text-emerald-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
              <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" />
            </svg>
            <span className="truncate max-w-[180px]">
              Focused: <span className="font-semibold">{nodes.find((n) => n.id === focusedEpicId)?.title || focusedEpicId}</span>
            </span>
            <button
              onClick={onExitFocusedEpic}
              className="ml-auto p-0.5 rounded hover:bg-emerald-200/50 dark:hover:bg-emerald-900/50 transition-colors flex-shrink-0"
              title="Show full graph"
            >
              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
              </svg>
            </button>
          </div>
        )}

        {/* Row 1: Layout shape controls */}
        <div className="flex w-full items-start gap-1.5 sm:gap-2">
          {/* Layout mode toggle */}
          <div className="flex w-full bg-white/90 dark:bg-zinc-900/90 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700 shadow-sm overflow-hidden" data-tutorial="layouts">
            <button
              onClick={() => setLayoutMode("force")}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                layoutMode === "force"
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Spring/force icon: scattered dots with connections */}
                  <circle cx="4" cy="4" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="3" cy="12" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="13" cy="11" r="1.5" fill="currentColor" stroke="none" />
                  <line x1="4" y1="4" x2="8" y2="8" strokeOpacity="0.5" />
                  <line x1="12" y1="3" x2="8" y2="8" strokeOpacity="0.5" />
                  <line x1="3" y1="12" x2="8" y2="8" strokeOpacity="0.5" />
                  <line x1="13" y1="11" x2="8" y2="8" strokeOpacity="0.5" />
                </svg>
                <span className="hidden sm:inline">Force</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />
            <button
              onClick={() => setLayoutMode("dag")}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                layoutMode === "dag"
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Tree/DAG icon: top-down hierarchy */}
                  <circle cx="8" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="2" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="6" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="12" cy="13.5" r="1.5" fill="currentColor" stroke="none" />
                  <line x1="8" y1="4" x2="4" y2="6.5" strokeOpacity="0.5" />
                  <line x1="8" y1="4" x2="12" y2="6.5" strokeOpacity="0.5" />
                  <line x1="4" y1="9.5" x2="2" y2="12" strokeOpacity="0.5" />
                  <line x1="4" y1="9.5" x2="6" y2="12" strokeOpacity="0.5" />
                  <line x1="12" y1="9.5" x2="12" y2="12" strokeOpacity="0.5" />
                </svg>
                <span className="hidden sm:inline">DAG</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />
            <button
              onClick={() => setLayoutMode("radial")}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                layoutMode === "radial"
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Radial icon: concentric rings with center dot */}
                  <circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" />
                  <circle cx="8" cy="8" r="5" fill="none" strokeOpacity="0.5" />
                  <circle cx="8" cy="8" r="7.5" fill="none" strokeOpacity="0.3" />
                </svg>
                <span className="hidden sm:inline">Radial</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />
            <button
              onClick={() => setLayoutMode("cluster")}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                layoutMode === "cluster"
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Cluster icon: two groups of dots */}
                  <circle cx="3.5" cy="4" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="6" cy="6" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="3" cy="7" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="11" cy="10" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="13.5" cy="11.5" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="11" cy="13" r="1.5" fill="currentColor" stroke="none" />
                </svg>
                <span className="hidden sm:inline">Cluster</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />
            <button
              onClick={() => setLayoutMode("spread")}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                layoutMode === "spread"
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Spread icon: dots spread far apart */}
                  <circle cx="2" cy="2" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="14" cy="3" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none" />
                  <circle cx="13" cy="13" r="1.5" fill="currentColor" stroke="none" />
                </svg>
                <span className="hidden sm:inline">Spread</span>
              </span>
            </button>
          </div>
        </div>

        {/* Row 2: View toggles */}
        <div className="flex w-full items-start gap-1.5 sm:gap-2" data-tutorial="view-controls">
          <div className="flex w-full bg-white/90 dark:bg-zinc-900/90 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700 shadow-sm overflow-hidden">
            {/* Collapse / Expand all toggle */}
            {(onCollapseAll || onExpandAll) && (
              <>
                <button
                  onClick={collapsedEpicIds && collapsedEpicIds.size > 0 ? onExpandAll : onCollapseAll}
                  className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
                  data-tutorial="btn-collapse"
                >
                  {collapsedEpicIds && collapsedEpicIds.size > 0 ? (
                    <span className="flex items-center gap-1.5">
                      <svg
                        className="w-3.5 h-3.5"
                        viewBox="0 0 24 24"
                        fill="none"
                        strokeWidth={1.5}
                        stroke="currentColor"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          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"
                        />
                      </svg>
                      <span className="hidden sm:inline">Expand all</span>
                    </span>
                  ) : (
                    <span className="flex items-center gap-1.5">
                      <svg
                        className="w-3.5 h-3.5"
                        viewBox="0 0 24 24"
                        fill="none"
                        strokeWidth={1.5}
                        stroke="currentColor"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          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"
                        />
                      </svg>
                      <span className="hidden sm:inline">Collapse all</span>
                    </span>
                  )}
                </button>
                <div className="w-px bg-zinc-200 dark:bg-zinc-700" />
              </>
            )}

            {/* Show/hide cluster labels toggle */}
            <button
              onClick={() => setShowClusters((v) => !v)}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                showClusters
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
              title={showClusters ? "Hide cluster labels" : "Show cluster labels"}
              data-tutorial="btn-clusters"
            >
              <span className="flex items-center gap-1.5">
                <svg
                  className="w-3.5 h-3.5"
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                >
                  {/* Dashed circle with label lines — cluster overlay icon */}
                  <circle cx="8" cy="8" r="6" strokeDasharray="2.5 2" strokeOpacity={showClusters ? 1 : 0.5} />
                  <line x1="5" y1="8" x2="11" y2="8" strokeOpacity={showClusters ? 1 : 0.4} />
                  <line x1="6" y1="10" x2="10" y2="10" strokeOpacity={showClusters ? 0.6 : 0.25} strokeWidth="1" />
                </svg>
                <span className="hidden sm:inline">Clusters</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />

            {/* Auto-fit: lock/unlock automatic camera reframing */}
            <button
              onClick={() => onAutoFitToggle?.()}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                autoFit
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
              title={autoFit ? "Auto-fit enabled: camera adjusts after updates" : "Auto-fit disabled: camera stays fixed"}
              data-tutorial="btn-autofit"
            >
              <span className="flex items-center gap-1.5">
                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
                  <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" />
                  <circle cx="12" cy="12" r="3" />
                </svg>
                <span className="hidden sm:inline">Auto-fit</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />

            {/* Pulse: highlight most recently active node */}
            <button
              onClick={() => onShowPulseToggle?.()}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                showPulse
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
              title={showPulse ? "Pulse enabled: ripple highlights most recent activity" : "Pulse disabled: no activity highlight"}
              data-tutorial="btn-pulse"
            >
              <span className="flex items-center gap-1.5">
                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
                  <circle cx="12" cy="12" r="3" />
                  <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" />
                  <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" />
                </svg>
                <span className="hidden sm:inline">Pulse</span>
              </span>
            </button>
            <div className="w-px bg-zinc-200 dark:bg-zinc-700" />

            {/* Particles: toggle flow particles on dependency links */}
            <button
              onClick={() => onShowParticlesToggle?.()}
              className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
                showParticles
                  ? "bg-emerald-500 text-white"
                  : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800"
              }`}
              title={showParticles ? "Particles enabled: flowing dots show dependency direction" : "Particles disabled: improves performance on large graphs"}
              data-tutorial="btn-particles"
            >
              <span className="flex items-center gap-1.5">
                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
                  <circle cx="5" cy="12" r="1.5" fill="currentColor" />
                  <circle cx="12" cy="12" r="1.5" fill="currentColor" />
                  <circle cx="19" cy="12" r="1.5" fill="currentColor" />
                  <path strokeLinecap="round" d="M7 12h3m3 0h3" strokeOpacity="0.4" />
                </svg>
                <span className="hidden sm:inline">Particles</span>
              </span>
            </button>
          </div>
        </div>
      </div>

      {/* Bottom-right info panel: stats + color mode selector + legend (hidden when timeline active) */}
      {!timelineActive && (
        <div
          className="absolute bottom-2 sm:bottom-4 z-10 bg-white/90 dark:bg-zinc-900/90 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700 shadow-sm px-2 sm:px-3 py-1.5 sm:py-2 text-xs text-zinc-400 dark:text-zinc-500 transition-[right] duration-300 ease-out"
          data-tutorial="legend"
          style={{ right: sidebarOpen ? "calc(360px + 1rem)" : "1rem", maxWidth: 320 }}
        >
          {filteredStats && (
            <div className="text-zinc-500 dark:text-zinc-400 mb-1.5" data-tutorial="legend-stats">
              <strong className="text-zinc-700 dark:text-zinc-300">{filteredStats.total}</strong> issues
              {" · "}
              <strong className="text-zinc-700 dark:text-zinc-300">{filteredStats.edges}</strong> deps
              {" · "}
              <strong className="text-emerald-600 dark:text-emerald-400">{filteredStats.prefixes.length}</strong>
              {filteredStats.prefixes.length === 1 ? " project" : " projects"}
              {activeLegendFilters && activeLegendFilters.size > 0 && (
                <span className="text-zinc-400 dark:text-zinc-500"> (filtered)</span>
              )}
            </div>
          )}
          {/* Color mode segmented control */}
          <div className="hidden sm:flex bg-zinc-100 dark:bg-zinc-800 rounded-md overflow-hidden mb-1.5" data-tutorial="legend-color-mode">
            {(["status", "priority", "type", "owner", "assignee", "prefix"] as ColorMode[]).map((mode) => (
              <button
                key={mode}
                onClick={() => onColorModeChange?.(mode)}
                className={`flex-1 px-2 py-1 text-[10px] font-medium transition-colors ${
                  colorMode === mode
                    ? "bg-emerald-500 text-white"
                    : "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60"
                }`}
              >
                {COLOR_MODE_LABELS[mode]}
              </button>
            ))}
          </div>
          {/* Dynamic legend: status/priority dots or person/prefix dots */}
          <div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mb-1.5 max-h-[120px] overflow-y-auto custom-scrollbar" data-tutorial="legend-items">
            {colorMode === "status" ? (
              <>
                {["open", "in_progress", "blocked", "deferred", "closed"].map((status) => {
                  const isActive = activeLegendFilters?.has(status);
                  const anyActive = activeLegendFilters && activeLegendFilters.size > 0;
                  return (
                    <button
                      key={status}
                      onClick={() => onToggleLegendFilter?.(status)}
                      onMouseEnter={() => {
                        hoveredLegendLabelRef.current = status;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      onMouseLeave={() => {
                        hoveredLegendLabelRef.current = null;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      title={`${STATUS_LABELS[status]} — click to filter`}
                      className={`flex items-center gap-1 px-1 py-0.5 rounded transition-all cursor-pointer ${
                        isActive
                          ? "bg-zinc-200/80 dark:bg-zinc-700/80 ring-1 ring-zinc-300 dark:ring-zinc-600"
                          : anyActive
                            ? "opacity-40 hover:opacity-70"
                            : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
                      }`}
                    >
                      <span
                        className="w-2 h-2 rounded-full flex-shrink-0"
                        style={{ backgroundColor: STATUS_COLORS[status] }}
                      />
                      <span className={`${isActive ? "text-zinc-800 dark:text-zinc-200 font-medium" : "text-zinc-500 dark:text-zinc-400"}`}>
                        {STATUS_LABELS[status]}
                      </span>
                    </button>
                  );
                })}
              </>
            ) : 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 (
                    <button
                      key={p}
                      onClick={() => onToggleLegendFilter?.(key)}
                      onMouseEnter={() => {
                        hoveredLegendLabelRef.current = key;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      onMouseLeave={() => {
                        hoveredLegendLabelRef.current = null;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      title={`${PRIORITY_LABELS[p]} — click to filter`}
                      className={`flex items-center gap-1 px-1 py-0.5 rounded transition-all cursor-pointer ${
                        isActive
                          ? "bg-zinc-200/80 dark:bg-zinc-700/80 ring-1 ring-zinc-300 dark:ring-zinc-600"
                          : anyActive
                            ? "opacity-40 hover:opacity-70"
                            : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
                      }`}
                    >
                      <span
                        className="w-2 h-2 rounded-full flex-shrink-0"
                        style={{ backgroundColor: PRIORITY_COLORS[p] }}
                      />
                      <span className={`${isActive ? "text-zinc-800 dark:text-zinc-200 font-medium" : "text-zinc-500 dark:text-zinc-400"}`}>
                        {PRIORITY_LABELS[p]}
                      </span>
                    </button>
                  );
                })}
              </>
            ) : colorMode === "type" ? (
              <>
                {["epic", "task", "bug", "feature", "chore"].map((type) => {
                  const isActive = activeLegendFilters?.has(type);
                  const anyActive = activeLegendFilters && activeLegendFilters.size > 0;
                  return (
                    <button
                      key={type}
                      onClick={() => onToggleLegendFilter?.(type)}
                      onMouseEnter={() => {
                        hoveredLegendLabelRef.current = type;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      onMouseLeave={() => {
                        hoveredLegendLabelRef.current = null;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      title={`${TYPE_LABELS[type]} — click to filter`}
                      className={`flex items-center gap-1 px-1 py-0.5 rounded transition-all cursor-pointer ${
                        isActive
                          ? "bg-zinc-200/80 dark:bg-zinc-700/80 ring-1 ring-zinc-300 dark:ring-zinc-600"
                          : anyActive
                            ? "opacity-40 hover:opacity-70"
                            : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
                      }`}
                    >
                      <span
                        className="w-2 h-2 rounded-full flex-shrink-0"
                        style={{ backgroundColor: TYPE_COLORS[type] }}
                      />
                      <span className={`${isActive ? "text-zinc-800 dark:text-zinc-200 font-medium" : "text-zinc-500 dark:text-zinc-400"}`}>
                        {TYPE_LABELS[type]}
                      </span>
                    </button>
                  );
                })}
              </>
            ) : (
              <>
                {legendItems.map(({ label, color }) => {
                  const isActive = activeLegendFilters?.has(label);
                  const anyActive = activeLegendFilters && activeLegendFilters.size > 0;
                  return (
                    <button
                      key={label}
                      onClick={() => onToggleLegendFilter?.(label)}
                      onMouseEnter={() => {
                        hoveredLegendLabelRef.current = label;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      onMouseLeave={() => {
                        hoveredLegendLabelRef.current = null;
                        cancelAnimationFrame(minimapRafRef.current);
                        minimapRafRef.current = requestAnimationFrame(() => redrawMinimapRef.current());
                      }}
                      title={`${label} — click to filter by ${colorMode}`}
                      className={`flex items-center gap-1 px-1 py-0.5 rounded transition-all cursor-pointer ${
                        isActive
                          ? "bg-zinc-200/80 dark:bg-zinc-700/80 ring-1 ring-zinc-300 dark:ring-zinc-600"
                          : anyActive
                            ? "opacity-40 hover:opacity-70"
                            : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
                      }`}
                    >
                      <span
                        className="w-2 h-2 rounded-full flex-shrink-0"
                        style={{ backgroundColor: color }}
                      />
                      <span className={`${isActive ? "text-zinc-800 dark:text-zinc-200 font-medium" : "text-zinc-500 dark:text-zinc-400"}`}>
                        {label}
                      </span>
                    </button>
                  );
                })}
              </>
            )}
            {activeLegendFilters && activeLegendFilters.size > 0 && (
              <button
                onClick={() => onClearLegendFilters?.()}
                className="text-[10px] text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-400 underline ml-auto"
              >
                Clear filters
              </button>
            )}
          </div>
          <div className="hidden sm:flex flex-col gap-0.5 text-zinc-400 dark:text-zinc-500">
            <span>
              Size = importance · Ring = project
              {colorMode !== "status" && ` · Fill = ${COLOR_MODE_LABELS[colorMode].toLowerCase()}`}
            </span>
          </div>
          <span className="sm:hidden">Tap a node for details</span>
        </div>
      )}

      {/* Minimap — bottom-left, hidden on mobile, resizable */}
      <div
        className="hidden sm:block absolute bottom-4 left-4 z-10"
        data-tutorial="minimap"
        style={{ width: MINIMAP_W, height: MINIMAP_H }}
      >
        <canvas
          ref={minimapCanvasRef}
          width={MINIMAP_W}
          height={MINIMAP_H}
          onClick={handleMinimapClick}
          className="rounded-lg border border-zinc-200 dark:border-zinc-700 shadow-sm cursor-crosshair bg-white dark:bg-zinc-900"
          style={{ width: MINIMAP_W, height: MINIMAP_H }}
        />
        {/* Resize handle — top edge */}
        <div
          className="absolute top-0 left-2 right-2 h-1.5 cursor-n-resize hover:bg-zinc-300/40 rounded-t-lg transition-colors"
          onMouseDown={(e) => {
            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 */}
        <div
          className="absolute top-2 right-0 bottom-2 w-1.5 cursor-e-resize hover:bg-zinc-300/40 rounded-r-lg transition-colors"
          onMouseDown={(e) => {
            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 */}
        <div
          className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize hover:bg-zinc-300/40 rounded-tr-lg transition-colors"
          onMouseDown={(e) => {
            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);
          }}
        />
      </div>

      {ForceGraph2D ? (
        <ForceGraph2D
          ref={graphRef}
          graphData={graphData}
          width={dimensions.width}
          height={dimensions.height}
          // Node rendering
          nodeCanvasObject={paintNode}
          nodeCanvasObjectMode={REPLACE_MODE}
          nodePointerAreaPaint={paintNodeArea}
          // Semantic zoom: draw cluster labels after nodes/links when zoomed out
          onRenderFramePost={paintClusterLabels}
          // Link rendering
          linkCanvasObject={paintLink}
          linkCanvasObjectMode={REPLACE_MODE}
          // Flow particles along blocks edges only (not parent-child)
          linkDirectionalParticles={(link: any) => (!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)}
        />
      ) : (
        <div className="flex items-center justify-center h-full">
          <div className="text-zinc-400 dark:text-zinc-500 text-sm animate-pulse-soft">
            Loading graph engine...
          </div>
        </div>
      )}

      {/* Marquee selection rectangle overlay */}
      {marquee && marquee.active && (
        <div
          style={{
            position: "absolute",
            left: Math.min(marquee.startX, marquee.currentX) - (containerRef.current?.getBoundingClientRect().left || 0),
            top: Math.min(marquee.startY, marquee.currentY) - (containerRef.current?.getBoundingClientRect().top || 0),
            width: Math.abs(marquee.currentX - marquee.startX),
            height: Math.abs(marquee.currentY - marquee.startY),
            border: "1.5px dashed #10b981",
            backgroundColor: "rgba(16, 185, 129, 0.08)",
            borderRadius: 4,
            pointerEvents: "none",
            zIndex: 5,
          }}
        />
      )}
    </div>
  );
});

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";
}
