// graph3d-stage.jsx — ForceGraph3D wrapper with map-style LOD.
//
// Implements the LOD spec faithfully:
//   - Buttons: left=rotate, middle/right=pan, wheel=dolly toward cursor (explicit mouseButtons map)
//   - DOM labels (avoids Sprite colorSpace bug); positioned via per-frame world→screen projection
//   - Three-pass label pipeline per frame:
//       Pass 1: frustum cull + focus filter, bucket by effLayer (= pathDepth - tierBonus)
//       Pass 2: per-bucket, sort by degree-percentile score, take top N (LAYER_INVIEW_CAP)
//       Pass 3: pixel-radius gate with 0.8·gate–gate fade band, 1.15× hysteresis, depth attenuation,
//               opacity quantized to 0.05 and font to 0.2 px (avoids style thrash)
//   - Tier promotion: degree percentile ≥0.95 → effLayer -= 2, ≥0.80 → effLayer -= 1
//   - Cluster halos: Sprite with radial-gradient CanvasTexture, distance-LOD only
//       (<380 hide / 380–700 fade / ≥700 cap 0.22 opacity), additive blend, renderOrder=-1
//   - Cluster names: DOM labels at centroid + bboxRadius + 22 world units up, attached at scene root
//       (NOT under a parent group — CSS-positioned divs don't inherit parent visibility correctly)
//       and shaped by relative depth between clusters only (their own depth-normalization channel)
//   - Per-frame: module-scoped scratch objects, zero alloc in tick
//
// Deferred (explicit, not silent):
//   - Per-frame edge opacity slope (Three.Line material access is fiddly; linkOpacity stays static)
//   - Fly-forward dolly (wheel capture beyond minDistance); current OrbitControls covers regular zoom
//   - Louvain communities — for now connected components stand in (small graph, mostly-sparse)

const { useEffect: useEffectS, useRef: useRefS, useState: useStateS } = React;

// ── Constants (spec) ─────────────────────────────────────────────────────────
const LAYER_PIXEL_GATE = [null, 1.5, 2.5, 3.5, 4.5, 5.0];      // index = effLayer (1..5)
const LAYER_INVIEW_CAP = [null,  5,   12,  24,  50,  120];
const FOCUS_NEAR_R     = 200;
const FOCUS_NEAR_R_SQ  = FOCUS_NEAR_R * FOCUS_NEAR_R;
const FOCUS_DIM_CLUSTER = 0.18;
const HYSTERESIS_RATIO = 1.15;

const HALO_DIST_HIDE   = 380;
const HALO_DIST_FULL   = 700;
const HALO_MAX_OPACITY = 0.22;

const NODE_OPACITY_MIN = 0.25;
const NODE_OPACITY_MAX = 1.0;
const EDGE_OPACITY     = 0.35;     // static baseline; per-frame slope deferred
const LABEL_OPACITY_MIN = 0.22;
const LABEL_OPACITY_MAX = 1.0;
const CLUSTER_LABEL_MIN = 0.32;

const FONT_BASE_PX = 12.5;          // base label font size
const FONT_MIN_PX  = 9;

// Slot color — three picks, three palettes (matches COLOR_SLOTS in interest-modal).
const SLOT_HUE = [232, 169, 36];   // Linear Purple / Mint Teal / Amber Mist

// ── Module-scoped scratch (lazy-init from window.THREE; zero alloc in tick) ──
const SCR = {};
function ensureScratch() {
  if (SCR.viewDir) return;
  const T = window.THREE;
  SCR.viewDir = new T.Vector3();
  SCR.frustum = new T.Frustum();
  SCR.projMat = new T.Matrix4();
  SCR.tmp1 = new T.Vector3();
  SCR.tmp2 = new T.Vector3();
  SCR.tmp3 = new T.Vector3();
}

// ── MiniMap projection helpers ──────────────────────────────────────────────
const MM_VB_W = 200;
const MM_VB_H = 130;
const MM_VB_PAD = 6;

// XZ orthographic projection of node positions into a 200×130 viewBox.
// Returns null if no positioned nodes.
function computeMiniProjection(nodes) {
  if (!nodes || nodes.length === 0) return null;
  let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity, count = 0;
  for (const n of nodes) {
    if (typeof n.x !== "number" || typeof n.z !== "number") continue;
    count++;
    if (n.x < minX) minX = n.x;
    if (n.x > maxX) maxX = n.x;
    if (n.z < minZ) minZ = n.z;
    if (n.z > maxZ) maxZ = n.z;
  }
  if (count === 0) return null;
  let dx = (maxX - minX) || 1;
  let dz = (maxZ - minZ) || 1;
  // 5% padding around bbox
  const padX = dx * 0.05, padZ = dz * 0.05;
  minX -= padX; maxX += padX; minZ -= padZ; maxZ += padZ;
  dx = maxX - minX; dz = maxZ - minZ;
  const availW = MM_VB_W - MM_VB_PAD * 2;
  const availH = MM_VB_H - MM_VB_PAD * 2;
  const scale = Math.min(availW / dx, availH / dz);
  const usedW = dx * scale, usedH = dz * scale;
  const offX = MM_VB_PAD + (availW - usedW) / 2;
  const offY = MM_VB_PAD + (availH - usedH) / 2;
  return {
    scale,
    minX, minZ,
    toView(x, z) {
      return { vx: offX + (x - minX) * scale, vy: offY + (z - minZ) * scale };
    },
    toWorld(vx, vy) {
      return { x: (vx - offX) / scale + minX, z: (vy - offY) / scale + minZ };
    },
  };
}

// ── Pure helpers ─────────────────────────────────────────────────────────────
function shapeForDepth(pathDepth) {
  const i = Math.max(0, Math.min(4, (pathDepth || 1) - 1));
  return ["sphere", "box", "octahedron", "cone", "tetrahedron"][i];
}
function nodeHue(n) {
  const slot = typeof n.pickSlot === "number" ? n.pickSlot : 0;
  return SLOT_HUE[slot] != null ? SLOT_HUE[slot] : SLOT_HUE[0];
}
function nodeColor(n) {
  // Slot drives hue, pathDepth modulates saturation (deeper = more muted).
  const sat = Math.max(0.30, 0.78 - (n.pathDepth - 1) * 0.11);
  const light = n.isSynthetic ? 0.45 : 0.60;
  const c = new window.THREE.Color();
  c.setHSL(nodeHue(n) / 360, sat, light);
  return c;
}
function buildGeometry(n) {
  const T = window.THREE, r = n.size || 2;
  switch (n.shape) {
    case "box":         return new T.BoxGeometry(r * 1.4, r * 1.4, r * 1.4);
    case "octahedron":  return new T.OctahedronGeometry(r * 1.0, 0);
    case "cone":        return new T.ConeGeometry(r * 0.95, r * 1.7, 8);
    case "tetrahedron": return new T.TetrahedronGeometry(r * 1.1, 0);
    case "sphere":
    default:            return new T.SphereGeometry(r * 0.85, 14, 12);
  }
}
function buildNodeMesh(node, materialRegistry) {
  const T = window.THREE;
  const color = nodeColor(node);
  // Annexed nodes get a soft emissive boost + slight geometry up-scale so they're
  // discoverable in the cloud without being garish.
  const emissive = node.isAnnexed ? 0.5 : (node.isMine ? 0.45 : 0.18);
  const mat = new T.MeshStandardMaterial({
    color, roughness: 0.55, metalness: 0.1,
    emissive: color, emissiveIntensity: emissive,
    transparent: true,
  });
  mat.__origOpacity = 1;
  mat.userData.baseEmissive = emissive;
  materialRegistry.set(node.id, mat);
  const geomNode = node.isAnnexed
    ? Object.assign({}, node, { size: (node.size || 2) * 1.3 })
    : node;
  return new T.Mesh(buildGeometry(geomNode), mat);
}

// Degree-percentile → effLayer tier bonus (spec).
function tierBonus(scorePctile) {
  if (scorePctile >= 0.95) return 2;
  if (scorePctile >= 0.80) return 1;
  return 0;
}

// Region halo radial-gradient texture, generated once.
let _haloTex = null;
function getHaloTexture() {
  if (_haloTex) return _haloTex;
  const T = window.THREE;
  const size = 256;
  const c = document.createElement("canvas");
  c.width = c.height = size;
  const ctx = c.getContext("2d");
  const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  g.addColorStop(0,   "rgba(255,255,255,1.00)");
  g.addColorStop(0.4, "rgba(220,225,255,0.55)");
  g.addColorStop(1,   "rgba(0,0,0,0)");
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new T.CanvasTexture(c);
  if (T.SRGBColorSpace) tex.colorSpace = T.SRGBColorSpace;
  _haloTex = tex;
  return tex;
}

// Quantize to reduce inline-style writes per frame.
const qOp   = (v) => Math.round(v * 20) / 20;       // step 0.05
const qFont = (v) => Math.round(v *  5) /  5;       // step 0.2 px

// ── Community detection: asynchronous label propagation ──────────────────────
// Spec asks for computeCommunities(); proper Louvain in pure JS is ~200 lines, but for
// the 800-node / ~1.8k-edge view a 5–8 iteration label-propagation converges to similar
// quality much faster. Each node starts with its own label; per pass, each node adopts
// the most frequent label among its neighbors (ties broken by sticking with current,
// else lowest id). Returns Map<communityLabel, memberId[]>.
function computeCommunities(nodes, links, maxIter = 8) {
  const adj = new Map();
  function add(a, b) {
    if (!adj.has(a)) adj.set(a, new Set());
    adj.get(a).add(b);
  }
  for (const l of links) {
    const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
    const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
    add(s, t); add(t, s);
  }

  const label = new Map(nodes.map(n => [n.id, n.id]));
  const order = nodes.map(n => n.id);

  for (let iter = 0; iter < maxIter; iter++) {
    // Fisher–Yates shuffle for visit order (prevents stable corner-cases)
    for (let i = order.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      const t = order[i]; order[i] = order[j]; order[j] = t;
    }
    let changed = 0;
    for (const id of order) {
      const ns = adj.get(id);
      if (!ns || ns.size === 0) continue;
      const counts = new Map();
      for (const nb of ns) {
        const lbl = label.get(nb);
        counts.set(lbl, (counts.get(lbl) || 0) + 1);
      }
      const curLbl = label.get(id);
      let bestLbl = curLbl;
      let bestCnt = counts.get(curLbl) || 0;
      for (const [lbl, cnt] of counts) {
        if (cnt > bestCnt || (cnt === bestCnt && lbl < bestLbl && lbl !== curLbl)) {
          bestCnt = cnt; bestLbl = lbl;
        }
      }
      if (bestLbl !== curLbl) { label.set(id, bestLbl); changed++; }
    }
    if (changed === 0) break;
  }

  const groups = new Map();
  for (const n of nodes) {
    const lbl = label.get(n.id);
    if (!groups.has(lbl)) groups.set(lbl, []);
    groups.get(lbl).push(n.id);
  }
  return groups;
}

// Map a value through a linear ramp clamped to [0,1].
function ramp(x, lo, hi) {
  if (x <= lo) return 0;
  if (x >= hi) return 1;
  return (x - lo) / (hi - lo);
}

// ── Component ────────────────────────────────────────────────────────────────
function GraphStage(props) {
  const {
    picks,           // [{slot, paths, tile_id, label_en, label_zh}]
    maxNodes,        // default 800
    perNodeK,        // default 5
    minCosine,       // default 0.65
    excludedPaths,   // Set<string> — NavIsland prune (optional)
    annexedNodes,    // additional nodes spliced in from out-of-view search hits
    annexedLinks,    // their bridge edges to existing view nodes
    highlightedLinkKeys, // Set<string> — amber path highlight (canonical "minId|maxId")
    highlightedNodeIds,  // Set<string> — amber node highlight (for relationship endpoints)
    onSelectNode, onDataLoaded, selectedNodeId,
  } = props;
  const FG = window.ForceGraph3D;

  // refs
  const fgRef = useRefS(null);
  const containerRef = useRefS(null);
  const labelLayerRef = useRefS(null);

  // Long-lived registries (rebuilt on each data load, mutated by RAF)
  const nodeMatRef    = useRefS(new Map());   // nodeId → MeshStandardMaterial
  const nodeLabelRef  = useRefS(new Map());   // nodeId → { div, lastShow, lastOp, lastFont }
  const clusterRef    = useRefS([]);          // [{ memberIds, hubId, hubName, labelDiv, haloSprite, lastOp }]
  const adjRef        = useRefS(new Map());   // nodeId → Set<neighborId>
  const tierRef       = useRefS(new Map());   // nodeId → { effLayer, score }
  const rafRef        = useRefS(null);
  // Selection lives in a ref so the RAF reads "current" without forcing the heavy
  // LOD-setup effect to re-run (which would tear down all DOM labels on every click).
  const selectedIdRef = useRefS(null);

  // state
  // rawData = exactly what the server returned (adapted). data = rawData ∩ excludedPaths.
  // We keep both because community detection / LOD / cluster names should all see the
  // currently visible set, but we don't want to re-fetch when the user just toggles a
  // checkbox in NavIsland.
  const [rawData, setRawData] = useStateS({ nodes: [], links: [] });
  const [size, setSize] = useStateS({ w: 800, h: 600 });
  const [status, setStatus] = useStateS("loading");
  const [errMsg, setErrMsg] = useStateS("");

  // Merge fetched rawData + annexed (from out-of-view search hits) → apply NavIsland filter.
  // Dedupe nodes by id, edges by "src|dst|kind".
  const data = React.useMemo(() => {
    const nodeMap = new Map();
    for (const n of (rawData.nodes || [])) nodeMap.set(n.id, n);
    for (const n of (annexedNodes || [])) if (!nodeMap.has(n.id)) nodeMap.set(n.id, n);

    const linkKey = (l) => {
      const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
      const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
      return s + "|" + t + "|" + (l.relationType || "");
    };
    const linkSet = new Set();
    const links = [];
    for (const l of (rawData.links || [])) {
      const k = linkKey(l); if (linkSet.has(k)) continue;
      linkSet.add(k); links.push(l);
    }
    for (const l of (annexedLinks || [])) {
      const k = linkKey(l); if (linkSet.has(k)) continue;
      linkSet.add(k); links.push(l);
    }
    const nodes = Array.from(nodeMap.values());

    if (!excludedPaths || excludedPaths.size === 0) return { nodes, links };
    return window.graphAdapter.applyFilters(nodes, links, excludedPaths);
  }, [rawData, annexedNodes, annexedLinks, excludedPaths]);

  // Picks key: a stable string that changes when the picks set changes.
  // Used as the re-fetch trigger.
  const picksKey = JSON.stringify(
    (picks || []).map(p => ({ s: p.slot, p: (p.paths || []).slice().sort() }))
  );

  // Keep selectedIdRef in sync with the prop without re-triggering the LOD setup.
  useEffectS(() => { selectedIdRef.current = selectedNodeId || null; }, [selectedNodeId]);

  // ── 1. Fetch bounded subgraph (KG interest view) ──────────────────────────
  useEffectS(() => {
    if (!window.graphRepo) {
      setStatus("error"); setErrMsg("graphRepo not initialized");
      return;
    }
    if (!Array.isArray(picks) || picks.length === 0) {
      setStatus("empty"); setData({ nodes: [], links: [] });
      return;
    }
    let cancelled = false;
    setStatus("loading");
    window.graphRepo.fetchInterestView(picks, {
      maxNodes:  maxNodes  ?? 800,
      perNodeK:  perNodeK  ?? 5,
      minCosine: minCosine ?? 0.65,
    })
      .then(g => {
        if (cancelled) return;
        if (!g.nodes.length) { setStatus("empty"); setRawData({ nodes: [], links: [] }); return; }
        setRawData({ nodes: g.nodes, links: g.links });
        setStatus("ready");
        onDataLoaded && onDataLoaded(g);
      })
      .catch(e => {
        if (cancelled) return;
        console.error("[GraphStage] fetch failed", e);
        setStatus("error"); setErrMsg(e.message || String(e));
      });
    return () => { cancelled = true; };
  }, [picksKey, maxNodes, perNodeK, minCosine]);

  // Bubble the filtered data up so graph.jsx can keep its stats badge + adjacency in sync.
  useEffectS(() => {
    if (status !== "ready") return;
    onDataLoaded && onDataLoaded(data);
  }, [data, status]);

  // ── 2. Container resize ───────────────────────────────────────────────────
  useEffectS(() => {
    if (!containerRef.current) return;
    const ro = new ResizeObserver(entries => {
      const cr = entries[0].contentRect;
      setSize({ w: Math.max(400, cr.width), h: Math.max(300, cr.height) });
    });
    ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, []);

  // ── 3. After data lands: tune forces, controls, mouseButtons, zoomToFit ──
  useEffectS(() => {
    if (status !== "ready" || !fgRef.current) return;
    const T = window.THREE;
    const fg = fgRef.current;

    if (typeof fg.d3Force === "function") {
      const charge = fg.d3Force("charge"); if (charge) charge.strength(-220);
      const link = fg.d3Force("link"); if (link) link.distance(l => l.linkDistance || 48);
    }

    if (typeof fg.controls === "function") {
      const ctl = fg.controls();
      if (ctl) {
        ctl.rotateSpeed = 1.0;
        ctl.panSpeed = 1.8;
        ctl.zoomSpeed = 2.2;
        ctl.enableDamping = true;
        ctl.dampingFactor = 0.15;
        ctl.screenSpacePanning = true;
        ctl.minPolarAngle = (5 * Math.PI) / 180;
        ctl.maxPolarAngle = (175 * Math.PI) / 180;
        ctl.minDistance = 8;
        ctl.maxDistance = 20000;
        if ("zoomToCursor" in ctl) ctl.zoomToCursor = true;
        // Explicit mouseButtons map per spec — left rotates, middle+right pan.
        if (T && T.MOUSE && ctl.mouseButtons) {
          ctl.mouseButtons.LEFT   = T.MOUSE.ROTATE;
          ctl.mouseButtons.MIDDLE = T.MOUSE.PAN;
          ctl.mouseButtons.RIGHT  = T.MOUSE.PAN;
        }
      }
    }

    const fitTimer = setTimeout(() => {
      if (fgRef.current && typeof fgRef.current.zoomToFit === "function") {
        fgRef.current.zoomToFit(1200, 80);
      }
    }, 900);
    return () => clearTimeout(fitTimer);
  }, [status, size.w, size.h]);

  // ── 4. LOD setup: build adjacency, percentiles, clusters, DOM labels, halos ──
  useEffectS(() => {
    if (status !== "ready" || !fgRef.current || !labelLayerRef.current) return;
    ensureScratch();
    const T = window.THREE;
    const fg = fgRef.current;
    const scene = typeof fg.scene === "function" ? fg.scene() : null;
    if (!scene) return;

    const layer = labelLayerRef.current;

    // Wipe prior registries
    nodeLabelRef.current.forEach(rec => { if (rec.div && rec.div.parentNode) rec.div.parentNode.removeChild(rec.div); });
    nodeLabelRef.current.clear();
    clusterRef.current.forEach(c => {
      if (c.labelDiv && c.labelDiv.parentNode) c.labelDiv.parentNode.removeChild(c.labelDiv);
      if (c.haloSprite) scene.remove(c.haloSprite);
    });
    clusterRef.current = [];
    adjRef.current.clear();
    tierRef.current.clear();

    // Build adjacency
    const nodeById = new Map(data.nodes.map(n => [n.id, n]));
    function addEdge(a, b) {
      if (!adjRef.current.has(a)) adjRef.current.set(a, new Set());
      adjRef.current.get(a).add(b);
    }
    data.links.forEach(l => {
      const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
      const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
      addEdge(s, t); addEdge(t, s);
    });

    // Degree → percentile → tier bonus → effLayer base (pathDepth - bonus, clamped to ≥1)
    const degs = data.nodes.map(n => ({ id: n.id, deg: (adjRef.current.get(n.id) || new Set()).size }));
    degs.sort((a, b) => a.deg - b.deg);
    const total = degs.length || 1;
    degs.forEach((rec, i) => {
      const pct = (i + 1) / total;
      const bonus = tierBonus(pct);
      const node = nodeById.get(rec.id);
      const base = Math.max(1, Math.min(5, (node.pathDepth || 5) - bonus));
      tierRef.current.set(rec.id, { effLayer: base, score: pct });
    });

    // Build a DOM label per node — visibility/opacity/font driven by RAF.
    data.nodes.forEach(n => {
      const div = document.createElement("div");
      div.className = "css-label";
      div.textContent = n.name || n.nameEn || "";
      div.style.display = "none";
      div.style.opacity = "0";
      layer.appendChild(div);
      nodeLabelRef.current.set(n.id, { div, lastShow: false, lastOp: 0, lastFont: FONT_BASE_PX });
    });

    // Communities via label propagation (replaces BFS connected components).
    // In a densely-connected view BFS collapses to ~1 huge LCC + dust; label propagation
    // teases out the real domain-clusters inside that LCC.
    const groups = computeCommunities(data.nodes, data.links, 8);
    const clusters = [];
    for (const [, members] of groups) {
      if (members.length < 5) continue;
      // Hub = highest kg_degree within the community (spec: "度数最高的节点").
      // kg_degree is the per-view degree computed server-side, more accurate than
      // recomputing local degree here.
      let hubId = members[0];
      let hubDeg = nodeById.get(hubId).kgDegree || 0;
      for (const m of members) {
        const n = nodeById.get(m);
        if (!n) continue;
        const d = n.kgDegree || 0;
        if (d > hubDeg) { hubDeg = d; hubId = m; }
      }
      const hubNode = nodeById.get(hubId);
      const hubName = (hubNode && (hubNode.name || hubNode.nameEn)) || "";
      if (!hubName) continue;

      // Cluster label div (DOM, layered overlay)
      const labelDiv = document.createElement("div");
      labelDiv.className = "css-cluster-name";
      labelDiv.textContent = hubName;
      labelDiv.style.display = "none";
      labelDiv.style.opacity = "0";
      layer.appendChild(labelDiv);

      // Halo sprite (Three) — additive blend, drawn under nodes
      const halo = new T.Sprite(new T.SpriteMaterial({
        map: getHaloTexture(),
        color: 0xb0c0ff,
        transparent: true,
        opacity: 0,
        blending: T.AdditiveBlending,
        depthWrite: false,
      }));
      halo.renderOrder = -1;
      halo.scale.set(1, 1, 1);
      halo.visible = false;
      scene.add(halo);

      clusters.push({
        memberIds: members,
        hubId, hubName,
        labelDiv, haloSprite: halo,
        lastOpName: 0, lastOpHalo: 0,
        // computed per frame:
        centroid: new T.Vector3(),
        bboxRadius: 0,
      });
    }
    clusterRef.current = clusters;
    console.info("[GraphStage] communities:", clusters.length,
      "(of", groups.size, "total groups, filtered to size ≥ 5)");

    // Hysteresis state for each node label (true once it crossed gate, false until it falls past gate/HYS)
    const hysState = new Map();
    data.nodes.forEach(n => hysState.set(n.id, false));

    // ── 5. RAF tick: three-pass label pipeline + per-frame ops ─────────────
    function tick() {
      const camera = typeof fg.camera === "function" ? fg.camera() : null;
      const ctl = typeof fg.controls === "function" ? fg.controls() : null;
      const w = size.w, h = size.h;
      const halfW = w * 0.5, halfH = h * 0.5;

      if (!camera) { rafRef.current = requestAnimationFrame(tick); return; }

      const fovRad = (camera.fov * Math.PI) / 180;
      const focalPx = h / (2 * Math.tan(fovRad / 2));

      // View direction = target → camera (per spec)
      const target = ctl ? ctl.target : SCR.tmp3.set(0, 0, 0);
      SCR.viewDir.copy(camera.position).sub(target).normalize();

      // Frustum
      SCR.projMat.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
      SCR.frustum.setFromProjectionMatrix(SCR.projMat);

      // ── Focus mode allowed set (selected + 1-hop + spatial neighbors within R) ──
      const selId = selectedIdRef.current;
      let focusSet = null;
      if (selId) {
        focusSet = new Set([selId]);
        const hop1 = adjRef.current.get(selId);
        if (hop1) hop1.forEach(id => focusSet.add(id));
        const sel = nodeById.get(selId);
        if (sel && typeof sel.x === "number") {
          for (const n of data.nodes) {
            if (focusSet.has(n.id)) continue;
            if (typeof n.x !== "number") continue;
            const dx = n.x - sel.x, dy = n.y - sel.y, dz = n.z - sel.z;
            if (dx * dx + dy * dy + dz * dz <= FOCUS_NEAR_R_SQ) focusSet.add(n.id);
          }
        }
      }

      // ── Compute per-node signed depth offset along viewDir; track range ──
      let depthMin = Infinity, depthMax = -Infinity;
      const nodeDepth = new Map(); // id → signed offset
      for (const n of data.nodes) {
        if (typeof n.x !== "number") continue;
        SCR.tmp1.set(n.x, n.y, n.z).sub(target);
        const off = SCR.tmp1.dot(SCR.viewDir);
        nodeDepth.set(n.id, off);
        if (off < depthMin) depthMin = off;
        if (off > depthMax) depthMax = off;
      }
      const depthRange = depthMax - depthMin || 1;

      // ── Pass 1: pre-filter (frustum + focus) → bucket by effLayer ─────────
      const buckets = [null, [], [], [], [], []]; // 1..5
      for (const n of data.nodes) {
        if (typeof n.x !== "number") continue;
        if (focusSet && !focusSet.has(n.id)) continue;

        SCR.tmp1.set(n.x, n.y, n.z);
        if (!SCR.frustum.containsPoint(SCR.tmp1)) continue;

        // distance camera→node for pixel radius
        const dx = camera.position.x - n.x;
        const dy = camera.position.y - n.y;
        const dz = camera.position.z - n.z;
        const dist = Math.hypot(dx, dy, dz);
        if (dist <= 0.01) continue;
        const worldR = n.size || 2;
        const pxR = (worldR / dist) * focalPx;

        const tier = tierRef.current.get(n.id);
        if (!tier) continue;
        const effLayer = Math.max(1, Math.min(5, tier.effLayer));
        const gate = LAYER_PIXEL_GATE[effLayer];

        // Hysteresis: once a label is on, it stays on until pxR < gate / HYSTERESIS
        const wasOn = hysState.get(n.id);
        const passNow = wasOn ? pxR >= gate / HYSTERESIS_RATIO : pxR >= gate;
        if (!passNow) {
          hysState.set(n.id, false);
          continue;
        }
        hysState.set(n.id, true);

        buckets[effLayer].push({ node: n, pxR, dist, gate, effLayer, score: tier.score });
      }

      // ── Pass 2: sort each bucket by score desc, take top N ────────────────
      const allowed = new Set();
      const passInfo = new Map(); // nodeId → {pxR, gate, depthT}
      for (let L = 1; L <= 5; L++) {
        const b = buckets[L];
        if (!b.length) continue;
        b.sort((a, b2) => b2.score - a.score);
        const cap = LAYER_INVIEW_CAP[L];
        const top = b.slice(0, cap);
        for (const item of top) {
          allowed.add(item.node.id);
          // depth in [0,1] relative to current visible point cloud
          const off = nodeDepth.get(item.node.id);
          const dT = (off - depthMin) / depthRange; // 0=back, 1=front
          passInfo.set(item.node.id, { pxR: item.pxR, gate: item.gate, depthT: dT });
        }
      }

      // ── Pass 3: write opacity/font to each label div ──────────────────────
      for (const [nodeId, rec] of nodeLabelRef.current) {
        if (!allowed.has(nodeId)) {
          if (rec.lastShow) {
            rec.div.style.display = "none";
            rec.lastShow = false;
          }
          continue;
        }
        const info = passInfo.get(nodeId);
        if (!info) continue;
        const n = nodeById.get(nodeId);

        // Project to screen
        SCR.tmp1.set(n.x, n.y, n.z).project(camera);
        // Reject behind camera or past far plane
        if (SCR.tmp1.z < -1 || SCR.tmp1.z > 1) {
          if (rec.lastShow) { rec.div.style.display = "none"; rec.lastShow = false; }
          continue;
        }
        const sx = SCR.tmp1.x * halfW + halfW;
        const sy = -SCR.tmp1.y * halfH + halfH;

        // Fade band: 0.8·gate → gate full opacity
        const bandLo = 0.8 * info.gate;
        const bandHi = info.gate;
        const fadeA = info.pxR >= bandHi ? 1 : ramp(info.pxR, bandLo, bandHi);

        // Depth attenuation: front (depthT≈1) full, back (depthT≈0) → LABEL_OPACITY_MIN
        const depthA = LABEL_OPACITY_MIN + (LABEL_OPACITY_MAX - LABEL_OPACITY_MIN) * info.depthT;
        // Font shrinks toward FONT_MIN_PX at depth back
        const fontPx = FONT_MIN_PX + (FONT_BASE_PX - FONT_MIN_PX) * info.depthT;

        let op = qOp(fadeA * depthA);
        let fnt = qFont(fontPx);

        if (op < 0.05) {
          if (rec.lastShow) { rec.div.style.display = "none"; rec.lastShow = false; }
          continue;
        }
        // Quantized writes only when something changed (cuts paint work)
        const xy = `translate3d(${sx | 0}px,${sy | 0}px,0) translate(-50%,-50%)`;
        rec.div.style.transform = xy;
        if (!rec.lastShow) {
          rec.div.style.display = "block";
          rec.lastShow = true;
        }
        if (op !== rec.lastOp) {
          rec.div.style.opacity = op;
          rec.lastOp = op;
        }
        if (fnt !== rec.lastFont) {
          rec.div.style.fontSize = fnt + "px";
          rec.lastFont = fnt;
        }
      }

      // ── Clusters ──────────────────────────────────────────────────────────
      // 1. Compute centroid + bbox radius per cluster
      // 2. Halo sprite: camera-distance LOD only
      // 3. Cluster name: relative depth among cluster centroids (separate normalization)
      const clusters = clusterRef.current;
      let cDepthMin = Infinity, cDepthMax = -Infinity;
      const cDepth = [];
      for (const c of clusters) {
        let cx = 0, cy = 0, cz = 0, count = 0;
        for (const id of c.memberIds) {
          const n = nodeById.get(id);
          if (n && typeof n.x === "number") {
            cx += n.x; cy += n.y; cz += n.z; count++;
          }
        }
        if (count === 0) { cDepth.push(0); continue; }
        cx /= count; cy /= count; cz /= count;
        c.centroid.set(cx, cy, cz);
        // bbox radius
        let r2max = 0;
        for (const id of c.memberIds) {
          const n = nodeById.get(id);
          if (!n || typeof n.x !== "number") continue;
          const dx = n.x - cx, dy = n.y - cy, dz = n.z - cz;
          const r2 = dx * dx + dy * dy + dz * dz;
          if (r2 > r2max) r2max = r2;
        }
        c.bboxRadius = Math.sqrt(r2max) + 24;

        // signed depth offset
        SCR.tmp2.copy(c.centroid).sub(target);
        const off = SCR.tmp2.dot(SCR.viewDir);
        cDepth.push(off);
        if (off < cDepthMin) cDepthMin = off;
        if (off > cDepthMax) cDepthMax = off;
      }
      const cDepthRange = cDepthMax - cDepthMin || 1;

      for (let i = 0; i < clusters.length; i++) {
        const c = clusters[i];
        // Halo: positioned at centroid, scaled by 2.4× bboxRadius, distance-LOD only
        const distToCam = camera.position.distanceTo(c.centroid);
        let haloOp;
        if (distToCam >= HALO_DIST_FULL) haloOp = HALO_MAX_OPACITY;
        else if (distToCam >= HALO_DIST_HIDE) haloOp = HALO_MAX_OPACITY * ramp(distToCam, HALO_DIST_HIDE, HALO_DIST_FULL);
        else haloOp = 0;

        const halo = c.haloSprite;
        if (halo) {
          halo.position.copy(c.centroid);
          const s = c.bboxRadius * 2.4;
          halo.scale.set(s, s, 1);
          if (halo.material) {
            const op = qOp(haloOp);
            if (op !== c.lastOpHalo) {
              halo.material.opacity = op;
              c.lastOpHalo = op;
            }
          }
          halo.visible = haloOp > 0.005;
        }

        // Cluster name (DOM): always-render, depth slope among cluster centroids only.
        // Position = centroid + (0, bboxRadius + 22, 0)
        SCR.tmp1.copy(c.centroid); SCR.tmp1.y += c.bboxRadius + 22;
        SCR.tmp1.project(camera);
        const nameDiv = c.labelDiv;
        if (SCR.tmp1.z < -1 || SCR.tmp1.z > 1) {
          if (nameDiv.style.display !== "none") nameDiv.style.display = "none";
          continue;
        }
        const sx = SCR.tmp1.x * halfW + halfW;
        const sy = -SCR.tmp1.y * halfH + halfH;

        const dT = (cDepth[i] - cDepthMin) / cDepthRange; // 0=back, 1=front (among clusters)
        let nameOp = CLUSTER_LABEL_MIN + (1 - CLUSTER_LABEL_MIN) * dT;
        if (focusSet) nameOp *= FOCUS_DIM_CLUSTER;
        nameOp = qOp(nameOp);

        nameDiv.style.transform = `translate3d(${sx | 0}px,${sy | 0}px,0) translate(-50%,-50%)`;
        if (nameDiv.style.display === "none") nameDiv.style.display = "block";
        if (nameOp !== c.lastOpName) {
          nameDiv.style.opacity = nameOp;
          c.lastOpName = nameOp;
        }
      }

      // Per-frame node geometry: depth attenuation + selection-driven emphasis.
      //   - selected node     → emissive 0.95, opacity full
      //   - 1-hop neighbor    → emissive 0.65, opacity full
      //   - everything else   → base emissive × 0.6, opacity × 0.32 (dim)
      // When nothing is selected, just the depth ramp applies.
      const selectionActive = !!selId;
      const hop1Set = (selId && adjRef.current.get(selId)) || null;
      for (const [nodeId, mat] of nodeMatRef.current) {
        const off = nodeDepth.get(nodeId);
        if (off == null) continue;
        const t = (off - depthMin) / depthRange;
        let op = NODE_OPACITY_MIN + (NODE_OPACITY_MAX - NODE_OPACITY_MIN) * t;
        const baseEm = mat.userData.baseEmissive ?? 0.18;
        let targetEm = baseEm;
        if (selectionActive) {
          if (nodeId === selId)              { targetEm = 0.95; /* op unchanged */ }
          else if (hop1Set && hop1Set.has(nodeId)) { targetEm = 0.65; }
          else                               { targetEm = baseEm * 0.6; op *= 0.32; }
        }
        const opQ = qOp(op);
        if (mat.opacity !== opQ) mat.opacity = opQ;
        if (Math.abs(mat.emissiveIntensity - targetEm) > 0.02) mat.emissiveIntensity = targetEm;
      }

      rafRef.current = requestAnimationFrame(tick);
    }
    rafRef.current = requestAnimationFrame(tick);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
      nodeLabelRef.current.forEach(rec => { if (rec.div && rec.div.parentNode) rec.div.parentNode.removeChild(rec.div); });
      nodeLabelRef.current.clear();
      clusterRef.current.forEach(c => {
        if (c.labelDiv && c.labelDiv.parentNode) c.labelDiv.parentNode.removeChild(c.labelDiv);
        if (c.haloSprite) scene.remove(c.haloSprite);
      });
      clusterRef.current = [];
    };
  }, [status, data, size.w, size.h]);

  // Camera-focus when selectedNodeId changes from any source (3D click, search hit,
  // annexed node, adjacent chip). Single source of truth.
  // Annexed nodes might have x/y/z assigned by us BEFORE d3-force settles them,
  // or might not have positions yet if force is still placing them — poll up to ~3s.
  useEffectS(() => {
    if (!selectedNodeId || !fgRef.current || !data || !data.nodes) return;
    let cancelled = false;
    let retries = 0;
    let timer = null;
    function tryFocus() {
      if (cancelled || retries > 15) return;
      const node = (data.nodes || []).find(n => n.id === selectedNodeId);
      if (!node) { retries++; timer = setTimeout(tryFocus, 200); return; }
      if (typeof node.x !== "number") {
        retries++;
        timer = setTimeout(tryFocus, 200);
        return;
      }
      const fg = fgRef.current;
      if (!fg || typeof fg.cameraPosition !== "function") return;

      // Frame the selected node + its 1-hop neighbors. Compute their centroid
      // as the new look-at target and a camera distance that puts the whole
      // neighborhood inside the viewport (with a small margin).
      const nodeMap = new Map((data.nodes || []).map(n => [n.id, n]));
      const points = [{ x: node.x, y: node.y, z: node.z }];
      const adj = adjRef.current.get(selectedNodeId);
      if (adj) {
        for (const id of adj) {
          const nb = nodeMap.get(id);
          if (nb && typeof nb.x === "number") points.push({ x: nb.x, y: nb.y, z: nb.z });
        }
      }

      // Centroid as the look-at point. Slight bias toward the selected node
      // so it doesn't drift off-center when the neighborhood is asymmetric.
      let cx = 0, cy = 0, cz = 0;
      for (const p of points) { cx += p.x; cy += p.y; cz += p.z; }
      cx /= points.length; cy /= points.length; cz /= points.length;
      cx = (cx + node.x) / 2; cy = (cy + node.y) / 2; cz = (cz + node.z) / 2;

      // Radius = farthest point from centroid (+ pad for node disks/labels).
      let r = 0;
      for (const p of points) {
        const d = Math.hypot(p.x - cx, p.y - cy, p.z - cz);
        if (d > r) r = d;
      }
      r = Math.max(r + 14, 50);

      // Distance so the bounding sphere fits within the (vertical) frustum.
      // Apply 1.25× margin so neighbors aren't kissing the viewport edge.
      const cam = fg.camera ? fg.camera() : null;
      const fovDeg = (cam && cam.fov) ? cam.fov : 50;
      const distance = (r / Math.tan((fovDeg * Math.PI / 180) / 2)) * 1.25;

      // Preserve current view angle (camera direction relative to old target).
      // If we don't have a camera yet, use a default diagonal offset.
      let dirX = 0.6, dirY = 0.45, dirZ = 0.8;
      if (cam && fg.controls) {
        const ctl = fg.controls();
        if (ctl && ctl.target) {
          dirX = cam.position.x - ctl.target.x;
          dirY = cam.position.y - ctl.target.y;
          dirZ = cam.position.z - ctl.target.z;
        }
      }
      const dlen = Math.hypot(dirX, dirY, dirZ) || 1;
      dirX /= dlen; dirY /= dlen; dirZ /= dlen;

      fg.cameraPosition(
        { x: cx + dirX * distance, y: cy + dirY * distance, z: cz + dirZ * distance },
        { x: cx, y: cy, z: cz },
        900
      );
    }
    tryFocus();
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
  }, [selectedNodeId, data.nodes]);

  const onNodeClickHandler = (node) => {
    onSelectNode && onSelectNode(node);
    // Camera focus is handled by the useEffect above; no imperative animation here.
  };

  return (
    <div ref={containerRef} className="graph-stage" style={{ position: "absolute", inset: 0 }}>
      {/* CSS label overlay (DOM labels for nodes and clusters; positioned per frame) */}
      <div ref={labelLayerRef} className="css-labels-layer" />

      {status === "loading" && (
        <div className="stage-overlay" data-state="loading"><span className="spinner" /><div className="stage-msg">加载图谱…</div></div>
      )}
      {status === "empty" && (
        <div className="stage-overlay" data-state="empty"><div className="stage-msg">所选领域下没有节点。试试切换 Domain。</div></div>
      )}
      {status === "error" && (
        <div className="stage-overlay" data-state="error"><div className="stage-msg">加载失败:{errMsg}</div></div>
      )}

      {FG && status === "ready" && (
        <MiniMap fgRef={fgRef} data={data} />
      )}
      {FG && status === "ready" && (
        <FG
          ref={fgRef}
          graphData={data}
          width={size.w}
          height={size.h}
          backgroundColor="rgba(0,0,0,0)"
          controlType="orbit"
          nodeThreeObject={(n) => buildNodeMesh(n, nodeMatRef.current)}
          nodeThreeObjectExtend={false}
          nodeLabel={null}
          // Adapter writes slot-aware color into each link as l.color (kind p/g/gg palette).
          // Bridge edges from annexed nodes are tinted amber + thicker so the new
          // connections to the existing graph are visually traceable.
          linkColor={(l) => {
            const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
            const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
            // Priority 1: amber path highlight (relationship / prereqs queries).
            if (highlightedLinkKeys && highlightedLinkKeys.size > 0) {
              const k = s < t ? (s + "|" + t) : (t + "|" + s);
              if (highlightedLinkKeys.has(k)) return "rgba(255,196,110,0.95)";
            }
            // Priority 2: selection neighborhood — touching the focused node = cyan-bright,
            // everything else = heavily dimmed so the connected subset pops.
            if (selectedNodeId) {
              if (s === selectedNodeId || t === selectedNodeId) return "rgba(140,220,255,0.92)";
              return "rgba(140,150,180,0.05)";
            }
            // Priority 3: annex bridges.
            if (l.isAnnexBridge) return "rgba(242,180,90,0.85)";
            return l.color || "rgba(200,200,220,0.24)";
          }}
          linkOpacity={EDGE_OPACITY}
          linkWidth={(l) => {
            const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
            const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
            if (highlightedLinkKeys && highlightedLinkKeys.size > 0) {
              const k = s < t ? (s + "|" + t) : (t + "|" + s);
              if (highlightedLinkKeys.has(k)) return 2.6;
            }
            if (selectedNodeId && (s === selectedNodeId || t === selectedNodeId)) return 2.2;
            if (l.isAnnexBridge) return 1.8;
            return 0.4 + 1.2 * (l.weight || 0.3);
          }}
          linkDirectionalArrowLength={(l) => (l.directed ? 3 : 0)}
          linkDirectionalArrowRelPos={1}
          enableNodeDrag={false}
          warmupTicks={80}
          cooldownTicks={140}
          onNodeClick={onNodeClickHandler}
          onBackgroundClick={() => onSelectNode && onSelectNode(null)}
        />
      )}
    </div>
  );
}

// ── MiniMap (Photoshop/Figma style camera navigator) ────────────────────────
// - 200×130 SVG, XZ orthographic projection of nodes
// - Viewport rect = camera frustum footprint on y=target plane
//                   (halfH = camDist·tan(fov/2), halfW = halfH·aspect)
// - 4 interactions:
//     click empty       → animate camera to that world point (350ms)
//     drag viewport     → realtime pan (cam+target both move; duration 0)
//     wheel             → dolly along (cam→target) line by 1.18× per tick
//     right-drag box    → marquee zoom-to-fit (camDist = halfH / tan(fov/2),
//                         preserving camera direction unit vector)
// - swallowClick prevents the post-drag pointerup from re-firing pan-on-click.
function MiniMap({ fgRef, data }) {
  const [, setTick] = useStateS(0);
  const svgRef = useRefS(null);
  const dragRef = useRefS({
    kind: null,             // null | 'frame-drag' | 'marquee' | 'click-pending'
    swallowClick: false,
    lastX: 0, lastY: 0,
    marqueeStart: null,
    marqueeEnd: null,
  });

  // RAF re-render so the dots + viewport stay in sync with the live simulation.
  useEffectS(() => {
    let raf = 0;
    function pump() {
      setTick(t => (t + 1) & 0xFFFF);
      raf = requestAnimationFrame(pump);
    }
    raf = requestAnimationFrame(pump);
    return () => cancelAnimationFrame(raf);
  }, []);

  if (!data || !data.nodes || data.nodes.length === 0) return null;
  const proj = computeMiniProjection(data.nodes);
  if (!proj) return null;

  // ── Viewport frame (camera footprint on y=target plane) ─────────────────
  let frameRect = null;
  let cam = null, ctl = null;
  const fg = fgRef.current;
  if (fg && typeof fg.camera === "function" && typeof fg.controls === "function") {
    cam = fg.camera();
    ctl = fg.controls();
    if (cam && ctl) {
      const t = ctl.target;
      const camDist = cam.position.distanceTo(t);
      const fovRad = (cam.fov * Math.PI) / 180;
      const halfH = camDist * Math.tan(fovRad / 2);
      const halfW = halfH * (cam.aspect || 1.5);
      const a = proj.toView(t.x - halfW, t.z - halfH);
      const b = proj.toView(t.x + halfW, t.z + halfH);
      frameRect = {
        x: Math.min(a.vx, b.vx),
        y: Math.min(a.vy, b.vy),
        w: Math.abs(b.vx - a.vx),
        h: Math.abs(b.vy - a.vy),
      };
    }
  }

  // ── Camera ops ──────────────────────────────────────────────────────────
  function panCamDelta(dxWorld, dzWorld, duration = 0) {
    if (!fg || !cam || !ctl) return;
    fg.cameraPosition(
      { x: cam.position.x + dxWorld, y: cam.position.y, z: cam.position.z + dzWorld },
      { x: ctl.target.x   + dxWorld, y: ctl.target.y,   z: ctl.target.z   + dzWorld },
      duration
    );
  }
  function panToWorld(wx, wz, duration = 350) {
    if (!fg || !ctl) return;
    panCamDelta(wx - ctl.target.x, wz - ctl.target.z, duration);
  }
  function dollyByFactor(factor) {
    if (!fg || !cam || !ctl) return;
    const t = ctl.target;
    fg.cameraPosition(
      {
        x: t.x + (cam.position.x - t.x) * factor,
        y: t.y + (cam.position.y - t.y) * factor,
        z: t.z + (cam.position.z - t.z) * factor,
      },
      t, 0
    );
  }
  function zoomToFitBox(wx0, wz0, wx1, wz1) {
    if (!fg || !cam || !ctl) return;
    const t = ctl.target;
    const centerX = (wx0 + wx1) / 2;
    const centerZ = (wz0 + wz1) / 2;
    const halfH = Math.max(20, Math.abs(wz1 - wz0) / 2);
    const fovRad = (cam.fov * Math.PI) / 180;
    const camDist = halfH / Math.tan(fovRad / 2);
    const dir = {
      x: cam.position.x - t.x,
      y: cam.position.y - t.y,
      z: cam.position.z - t.z,
    };
    const dlen = Math.hypot(dir.x, dir.y, dir.z) || 1;
    const u = { x: dir.x / dlen, y: dir.y / dlen, z: dir.z / dlen };
    fg.cameraPosition(
      { x: centerX + u.x * camDist, y: t.y + u.y * camDist, z: centerZ + u.z * camDist },
      { x: centerX, y: t.y, z: centerZ },
      400
    );
  }

  // ── Pointer geometry → SVG viewbox coordinates ──────────────────────────
  function svgPt(e) {
    const svg = svgRef.current;
    if (!svg) return null;
    const r = svg.getBoundingClientRect();
    return {
      vx: ((e.clientX - r.left) / r.width)  * MM_VB_W,
      vy: ((e.clientY - r.top)  / r.height) * MM_VB_H,
    };
  }

  function onPointerDown(e) {
    const pt = svgPt(e); if (!pt) return;
    e.preventDefault();
    try { svgRef.current.setPointerCapture(e.pointerId); } catch {}

    if (e.button === 2) {
      // Right button → marquee zoom-to-fit
      dragRef.current = { kind: "marquee", swallowClick: true, marqueeStart: pt, marqueeEnd: pt };
      setTick(t => t + 1);
      return;
    }
    if (frameRect &&
        pt.vx >= frameRect.x && pt.vx <= frameRect.x + frameRect.w &&
        pt.vy >= frameRect.y && pt.vy <= frameRect.y + frameRect.h) {
      dragRef.current = { kind: "frame-drag", swallowClick: false, lastX: pt.vx, lastY: pt.vy };
      return;
    }
    dragRef.current = { kind: "click-pending", swallowClick: false, lastX: pt.vx, lastY: pt.vy };
  }
  function onPointerMove(e) {
    const pt = svgPt(e); if (!pt) return;
    const d = dragRef.current;
    if (!d.kind) return;
    if (d.kind === "frame-drag") {
      const dvx = pt.vx - d.lastX, dvy = pt.vy - d.lastY;
      d.lastX = pt.vx; d.lastY = pt.vy;
      if (Math.abs(dvx) > 0.5 || Math.abs(dvy) > 0.5) d.swallowClick = true;
      panCamDelta(dvx / proj.scale, dvy / proj.scale, 0);
    } else if (d.kind === "marquee") {
      d.marqueeEnd = pt;
      setTick(t => t + 1);
    } else if (d.kind === "click-pending") {
      const dvx = pt.vx - d.lastX, dvy = pt.vy - d.lastY;
      if (Math.hypot(dvx, dvy) > 1) d.swallowClick = true;
    }
  }
  function onPointerUp(e) {
    const d = dragRef.current;
    if (d.kind === "marquee" && d.marqueeStart && d.marqueeEnd) {
      const a = d.marqueeStart, b = d.marqueeEnd;
      if (Math.hypot(a.vx - b.vx, a.vy - b.vy) > 5) {
        const wa = proj.toWorld(Math.min(a.vx, b.vx), Math.min(a.vy, b.vy));
        const wb = proj.toWorld(Math.max(a.vx, b.vx), Math.max(a.vy, b.vy));
        zoomToFitBox(wa.x, wa.z, wb.x, wb.z);
      }
    }
    dragRef.current = { kind: null, swallowClick: d.swallowClick, lastX: 0, lastY: 0, marqueeStart: null, marqueeEnd: null };
    setTick(t => t + 1);
  }
  function onClick(e) {
    const d = dragRef.current;
    if (d.swallowClick) { d.swallowClick = false; return; }
    const pt = svgPt(e); if (!pt) return;
    const w = proj.toWorld(pt.vx, pt.vy);
    panToWorld(w.x, w.z, 350);
  }
  function onWheel(e) {
    e.preventDefault();
    const factor = e.deltaY > 0 ? 1.18 : 1 / 1.18;
    dollyByFactor(factor);
  }

  // ── Render dots + viewport rect + marquee ───────────────────────────────
  const dots = [];
  const dotColors = ["#7C8CFF", "#6BC9B1", "#F2B45A"];
  for (const n of data.nodes) {
    if (typeof n.x !== "number" || typeof n.z !== "number") continue;
    const p = proj.toView(n.x, n.z);
    const slot = typeof n.pickSlot === "number" ? n.pickSlot : 0;
    const fill = dotColors[slot] || dotColors[0];
    dots.push(<circle key={n.id} cx={p.vx} cy={p.vy} r={1.3} fill={fill} opacity={0.7} />);
  }

  const d = dragRef.current;
  let marqueeRect = null;
  if (d.kind === "marquee" && d.marqueeStart && d.marqueeEnd) {
    const a = d.marqueeStart, b = d.marqueeEnd;
    marqueeRect = {
      x: Math.min(a.vx, b.vx),
      y: Math.min(a.vy, b.vy),
      w: Math.abs(b.vx - a.vx),
      h: Math.abs(b.vy - a.vy),
    };
  }

  return (
    <div className="minimap">
      <div className="minimap-head">地图 · 概览</div>
      <svg
        ref={svgRef}
        className="minimap-svg"
        viewBox={`0 0 ${MM_VB_W} ${MM_VB_H}`}
        preserveAspectRatio="none"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onWheel={onWheel}
        onClick={onClick}
        onContextMenu={(e) => e.preventDefault()}
      >
        <rect x="0" y="0" width={MM_VB_W} height={MM_VB_H} fill="rgba(15,18,28,0.55)" />
        <g>{dots}</g>
        {frameRect && (
          <rect
            x={frameRect.x} y={frameRect.y}
            width={frameRect.w} height={frameRect.h}
            fill="rgba(255,255,255,0.05)"
            stroke="rgba(255,255,255,0.55)"
            strokeWidth={0.7}
            style={{ cursor: "grab" }}
          />
        )}
        {marqueeRect && (
          <rect
            x={marqueeRect.x} y={marqueeRect.y}
            width={marqueeRect.w} height={marqueeRect.h}
            fill="rgba(124,140,255,0.18)"
            stroke="#7C8CFF"
            strokeWidth={0.6}
            strokeDasharray="2 1.5"
          />
        )}
      </svg>
    </div>
  );
}

window.GraphStage = GraphStage;
