// REDESCUBRIENDO — 3D galaxy network graph in canvas (pure JS, no library)

const TYPE_COLORS = {
  person:    "#7ee0ff",
  agency:    "#ffb86b",
  event:     "#ff6b9d",
  program:   "#b48cff",
  concept:   "#7cffb8",
  channel:   "#ffe06b",
  phenomenon:"#ffffff"
};

const TYPE_LABEL_ES = {
  person:    "Personas",
  agency:    "Agencias",
  event:     "Eventos",
  program:   "Programas",
  concept:   "Conceptos",
  channel:   "Canales",
  phenomenon:"Fenómeno"
};

function NetworkGraph({ data, selectedId, onSelect, filters, tweaks, focusId, chatMode }) {
  const canvasRef = React.useRef(null);
  const containerRef = React.useRef(null);
  const stateRef = React.useRef({
    nodes: [],
    edges: [],
    rotX: 0.68,
    rotY: 0,
    zoom: 1.0,
    pan: { x: 0, y: 0 },
    galaxyRot: 0,
    convergence: 0,        // 0 = normal galaxy, 1 = fully converged (chat orb mode)
    pulse: 0,              // for the thinking pulse
    drag: null,
    pending: null,
    rotate: null,
    panDrag: null,
    hover: null,
    raf: null,
    lastTime: 0,
    lastInteraction: 0,
    size: { w: 800, h: 600 },
    alpha: 1
  });
  // Sync chatMode into state ref so the render loop reads the latest
  stateRef.current.chatMode = chatMode || "closed";

  // ============== BUILD NODES + HOME POSITIONS (3D) ==============
  React.useEffect(() => {
    const st = stateRef.current;
    if (containerRef.current) {
      const r = containerRef.current.getBoundingClientRect();
      if (r.width > 0 && r.height > 0) st.size = { w: r.width, h: r.height };
    }
    const activeTypes = new Set(Object.keys(filters.types).filter(k => filters.types[k]));
    const activeBlocs = new Set(Object.keys(filters.blocs).filter(k => filters.blocs[k]).map(Number));

    const visibleNodes = data.nodes.filter(n => {
      if (!activeTypes.has(n.type)) return false;
      if (activeBlocs.size && n.blocs && n.blocs.length) {
        if (!n.blocs.some(b => activeBlocs.has(b))) return false;
      }
      return true;
    });
    const idSet = new Set(visibleNodes.map(n => n.id));
    const visibleEdges = data.edges.filter(e => idSet.has(e[0]) && idSet.has(e[1]));

    // === 3D GALAXY HOME POSITIONS ===
    // Grand-design TWO-arm logarithmic spiral with a dense central bulge.
    // Disk lies in the XZ plane; Y is thickness (thin → flat disc). Density is
    // highest at the centre (bulge) and falls off outward; the two arms trace
    // the rotation and define the rim.
    const minDim = Math.min(st.size.w, st.size.h);
    const maxR = minDim * 0.46;
    const donutInner = minDim * 0.045; // black-hole hole at the very centre
    const NUM_ARMS = 2;
    const ARM_WIND = 3.1;              // radians an arm sweeps from centre→rim
    const ARM_HALF = 1.15;             // angular half-spread: arm visible but soft (not a thin chain)
    const BULGE_FRAC = 0.24;           // f below this = amorphous central bulge
    const thickness = minDim * 0.02;
    st.donutR = minDim * 0.07;         // launch torus radius (used by the intro)

    let maxDeg = 1;
    for (const n of visibleNodes) maxDeg = Math.max(maxDeg, n.degree || 0);

    // Centre-weighted radius fraction: most nodes sit near the core, fewer at
    // the rim (pow > 1 concentrates toward 0). High-degree nodes nudged inward.
    const radiusFrac = (n, i) => {
      const dn = (n.degree || 0) / maxDeg;
      const rphase = (i * 0.7548776662) % 1;            // even spread 0..1
      let u = 0.82 * rphase + 0.18 * (1 - dn);
      u = Math.max(0, Math.min(1, u));
      return Math.pow(u, 1.9);                           // dense centre, light rim
    };

    const computeHome = (n, i) => {
      const c = n.canal || 0;
      const phase = (i * 0.6180339) % 1;                          // angular jitter
      const armSel = (((i * 0.3819660) % 1) < 0.5) ? 0 : 1;       // ~50/50 → 2 arms
      const yJitter = (Math.sin(i * 12.9898) * 43758.5453 % 1) - 0.5;
      const frac = n.type === "channel"
        ? 0.42 + 0.46 * ((c * 0.3819660) % 1)   // channels spread across mid-outer disc
        : radiusFrac(n, i);
      // Radial jitter so nodes don't sit on exact spiral lines (breaks the
      // "necklace" look and fills the disc).
      const rHash = Math.sin(i * 91.7) * 23456.789;
      const rJit = rHash - Math.floor(rHash);            // 0..1
      const homeR = (donutInner + (maxR - donutInner) * frac) * (1 + 0.13 * (rJit - 0.5) * 2);
      let homeA;
      if (frac < BULGE_FRAC) {
        // Amorphous, dense central bulge.
        homeA = phase * Math.PI * 2;
      } else {
        const baseA = armSel * Math.PI;                  // two arms, 180° apart
        const wind = ARM_WIND * frac;                    // logarithmic winding
        // Wide, soft scatter: density peaks on the arm spine and fades out, so
        // the arm is only *suggested* and the surrounding disc fills in — not a
        // hard chain of beads.
        const s = phase - 0.5;
        const soft = Math.sign(s) * Math.pow(Math.abs(s) * 2, 1.8);
        homeA = baseA + wind + soft * ARM_HALF;
      }
      // Bulge a touch puffier; disc thin and flat.
      const yScale = frac < BULGE_FRAC ? 0.9 : 0.28;
      return {
        hx: Math.cos(homeA) * homeR,
        hy: yJitter * thickness * yScale,
        hz: Math.sin(homeA) * homeR,
        frac
      };
    };

    const NV = visibleNodes.length;
    st.nodes = visibleNodes.map((n, i) => {
      const home = computeHome(n, i);
      // Precompute a unit Fibonacci-sphere direction ONCE (for the chat orb), so
      // the render loop never recomputes trig per node per frame.
      const phi = Math.acos(1 - 2 * (i + 0.5) / NV);
      const theta = Math.PI * (1 + Math.sqrt(5)) * (i + 0.5);
      const ux = Math.sin(phi) * Math.cos(theta);
      const uy = Math.cos(phi);
      const uz = Math.sin(phi) * Math.sin(theta);
      // The phenomenon stays anchored at the galactic center
      if (n.type === "phenomenon") {
        return {
          ...n,
          hx: 0, hy: 0, hz: 0, homeR: 0, homeA: 0, ux: 0, uy: 0, uz: 0,
          x: 0, y: 0, z: 0,
          vx: 0, vy: 0, vz: 0,
          deg: 0, px: 0, py: 0, scale: 1, depth: 0
        };
      }
      return {
        ...n,
        hx: home.hx, hy: home.hy, hz: home.hz,
        homeR: Math.hypot(home.hx, home.hz),
        homeA: Math.atan2(home.hz, home.hx),
        homeFrac: home.frac,
        ux, uy, uz,
        // Start collapsed at the galactic centre; the launch flings them out.
        x: 0, y: 0, z: 0,
        vx: 0, vy: 0, vz: 0,
        deg: 0,
        px: 0, py: 0, scale: 1, depth: 0
      };
    });
    const nodeById = {};
    st.nodes.forEach(n => { nodeById[n.id] = n; });
    st.edges = visibleEdges.map(e => {
      const s = nodeById[e[0]]; const t = nodeById[e[1]];
      s.deg++; t.deg++;
      return { source: s, target: t, kind: e[2], note: e[3] || "" };
    });

    // Trigger the elegant spiral intro. Start timestamp is captured on the
    // first render frame so it is driven by wall-clock time (identical pacing
    // on desktop, tablet and phone — no physics, no vibration).
    st.intro = { start: null, dur: 1500, dir: 1 };
  }, [data, filters]);

  // ============== CANVAS SIZING ==============
  React.useEffect(() => {
    if (!containerRef.current) return;
    const applySize = (w, h) => {
      const cv = canvasRef.current;
      if (!cv) return;
      const dpr = window.devicePixelRatio || 1;
      cv.width = w * dpr;
      cv.height = h * dpr;
      cv.style.width = w + "px";
      cv.style.height = h + "px";
      const ctx = cv.getContext("2d");
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      stateRef.current.size = { w, h };
    };
    const rect = containerRef.current.getBoundingClientRect();
    if (rect.width > 0 && rect.height > 0) applySize(rect.width, rect.height);
    const ro = new ResizeObserver(entries => {
      const e = entries[0];
      applySize(e.contentRect.width, e.contentRect.height);
    });
    ro.observe(containerRef.current);
    return () => ro.disconnect();
  }, []);

  // ============== FOCUS ON NODE (zoom + center) ==============
  React.useEffect(() => {
    if (!focusId) return;
    const st = stateRef.current;
    const id = focusId.split(":")[0];
    const node = st.nodes.find(n => n.id === id);
    if (!node) return;
    // Re-center pan so node sits in the middle of the VISIBLE area.
    // The detail panel (380px) covers the right side, so shift target left by ~190px.
    const PANEL_W = 380;
    const targetX = (st.size.w - PANEL_W) / 2;
    const proj = projectPoint(node.x, node.y, node.z, st);
    st.pan.x -= (proj.px - targetX);
    st.pan.y -= (proj.py - st.size.h / 2);
    st.alpha = Math.max(st.alpha, 0.3);
  }, [focusId]);

  // ============== RE-FORM THE GALAXY ON DESELECT ==============
  // When the user closes a panel / deselects, replay the spiral so the galaxy
  // gracefully re-forms (same elegant revolution as the intro).
  const prevSelRef = React.useRef(selectedId);
  React.useEffect(() => {
    const prev = prevSelRef.current;
    prevSelRef.current = selectedId;
    if (prev && !selectedId) {
      const st = stateRef.current;
      const chatActive = st.chatMode && st.chatMode !== "closed";
      if (!chatActive) {
        st.pan = { x: 0, y: 0 };               // recenter the galaxy
        st.intro = { start: null, dur: 1200, dir: 1 };
      }
    }
  }, [selectedId]);

  // ============== RENDER + SIMULATION LOOP ==============
  React.useEffect(() => {
    const cv = canvasRef.current;
    const ctx = cv.getContext("2d");
    const st = stateRef.current;
    st.alpha = 1;

    const step = (t) => {
      const dt = t - (st.lastTime || t);
      st.lastTime = t;
      const W = st.size.w, H = st.size.h;

      // Auto-rotation when idle (galaxy spins around its center; camera stays still)
      const isInteracting = st.drag || st.rotate || st.panDrag || st.pending;
      const idleMs = t - (st.lastInteraction || 0);
      const chatActive = st.chatMode && st.chatMode !== "closed";
      const shouldAutoRotate = !selectedId && !isInteracting && idleMs > 1500 && !chatActive;
      if (shouldAutoRotate) {
        st.galaxyRot += 0.000045 * dt;
      } else if (chatActive) {
        st.galaxyRot += 0.00008 * dt;   // gentle spin of the orb (free via projection)
      }

      // Convergence ramp: 0 = normal galaxy, 1 = converged into the chat orb.
      // Time-based (exponential toward target) so it feels identical and fluid
      // on desktop, tablet and phone — independent of frame rate.
      const targetConv = chatActive ? 1 : 0;
      st.convergence += (targetConv - st.convergence) * (1 - Math.exp(-dt / 140));
      if (st.convergence < 0.0005) st.convergence = 0;
      st.pulse += (st.chatMode === "thinking" ? 0.003 : 0.0008) * dt;

      // === LAYOUT: deterministic spiral intro + gentle home settle ===
      // No force simulation → no vibration. Positions are driven by wall-clock
      // time so the motion is identical on every device.
      if (st.intro) {
        if (st.intro.start == null) st.intro.start = t;
        const P = Math.min(1, (t - st.intro.start) / st.intro.dur);
        const SPREAD = 0.18;                 // mild stagger (gentle, not explosive)
        const SWEEP = Math.PI * 0.6;         // soft partial turn (~108°), few turns
        const donutR = st.donutR || (Math.min(st.size.w, st.size.h) * 0.07);
        let allDone = true;
        for (let i = 0; i < st.nodes.length; i++) {
          const n = st.nodes[i];
          if (n.type === "phenomenon") { n.x = n.y = n.z = 0; continue; }
          if (n === st.drag) continue;
          // Nodes are released from a small TORUS around the black hole (not the
          // singular point) and drift out to their orbit with a soft rotation in
          // the SAME sense as the final spin. Radius decelerates (ease-out).
          const delay = SPREAD * n.homeFrac;
          const p = Math.max(0, Math.min(1, (P - delay) / (1 - delay)));
          if (p < 1) allDone = false;
          const e = easeOutCubic(p);
          const r = donutR + (n.homeR - donutR) * e;
          const ang = n.homeA + SWEEP * (1 - e) * st.intro.dir;
          n.x = Math.cos(ang) * r;
          n.z = Math.sin(ang) * r;
          n.y = n.hy * e;
        }
        if (P >= 1 && allDone) {
          for (const n of st.nodes) {
            if (n.type !== "phenomenon" && n !== st.drag) { n.x = n.hx; n.y = n.hy; n.z = n.hz; }
          }
          st.intro = null;
        }
      } else {
        // Gentle critically-damped pull to home (no overshoot, no jitter).
        // Handles drag spring-back; otherwise nodes simply rest at home.
        const k = 1 - Math.exp(-dt * 0.012);
        for (let i = 0; i < st.nodes.length; i++) {
          const n = st.nodes[i];
          if (n.type === "phenomenon" || n === st.drag) continue;
          n.x += (n.hx - n.x) * k;
          n.y += (n.hy - n.y) * k;
          n.z += (n.hz - n.z) * k;
        }
      }

      // Project all nodes, blending the galaxy position with the chat orb (a
      // thin shell around the central black hole). The orb direction per node is
      // precomputed (unit Fibonacci sphere); here it's just a cheap lerp.
      const conv = st.convergence;
      const N = st.nodes.length;
      const minDim = Math.min(st.size.w, st.size.h);
      // Global breathing pulse when thinking (one sin for the whole orb, not per node).
      const pulse = st.chatMode === "thinking" ? (1 + 0.05 * Math.sin(st.pulse)) : 1;
      const orbR = minDim * 0.2 * pulse;

      for (let i = 0; i < N; i++) {
        const n = st.nodes[i];
        let ex, ey, ez;
        if (n.type === "phenomenon" || conv < 0.001) {
          // Phenomenon stays at the centre; everything else is the plain galaxy.
          ex = n.x * (1 - conv);
          ey = n.y * (1 - conv);
          ez = n.z * (1 - conv);
        } else {
          ex = n.x * (1 - conv) + n.ux * orbR * conv;
          ey = n.y * (1 - conv) + n.uy * orbR * conv;
          ez = n.z * (1 - conv) + n.uz * orbR * conv;
        }
        const p = projectPoint(ex, ey, ez, st);
        n.px = p.px; n.py = p.py; n.scale = p.scale; n.depth = p.depth;
      }

      // === RENDER ===
      ctx.clearRect(0, 0, W, H);

      // Galaxy backdrop in 3D — render rings projected
      drawGalaxyBackdrop(ctx, st);

      // Determine highlight set
      const selectedNode = selectedId ? st.nodes.find(n => n.id === selectedId) : null;
      const focus = selectedNode || st.hover;
      const connected = new Set();
      if (focus) {
        connected.add(focus.id);
        for (const e of st.edges) {
          if (e.source === focus) connected.add(e.target.id);
          if (e.target === focus) connected.add(e.source.id);
        }
      }
      const dim = !!focus;

      // === AMBIENT CONSTELLATIONS ===
      // The full web of 561 links is just a tangle, so by default it stays
      // hidden. Instead we softly cycle a random node's "constellation": its
      // links (and the nodes they touch) glow in and out, so the galaxy feels
      // alive and hints at connectivity without permanent clutter. Hover/select
      // still lights a node up fully.
      // Timing: 5 s of plain orbiting first, then a constellation glows for 6 s
      // and a new one appears every 12 s (with a calm gap in between).
      const AMB_DELAY = 5000, AMB_GLOW = 6000, AMB_INTERVAL = 12000;
      if (st.intro) st.ambClock0 = null;   // restart the calm period after a (re)form
      let ambNode = null, ambAlpha = 0, ambSet = null;
      const canAmbient = !focus && st.convergence < 0.05 && !isInteracting && !st.intro;
      if (canAmbient) {
        if (st.ambClock0 == null) st.ambClock0 = t;
        const elapsed = t - st.ambClock0;
        if (elapsed >= AMB_DELAY) {
          const localT = elapsed - AMB_DELAY;
          const cycle = Math.floor(localT / AMB_INTERVAL);
          const inCycle = localT - cycle * AMB_INTERVAL;
          if (!st.constellation || st.constellation.cycle !== cycle || !st.nodes.includes(st.constellation.node)) {
            const pool = st.nodes.filter(n => n.type !== "phenomenon" && (n.deg || 0) >= 6);
            const arr = pool.length ? pool : st.nodes.filter(n => n.type !== "phenomenon");
            const pick = arr[Math.floor(Math.random() * arr.length)];
            const neigh = new Set([pick.id]);
            for (const e of st.edges) {
              if (e.source === pick) neigh.add(e.target.id);
              if (e.target === pick) neigh.add(e.source.id);
            }
            st.constellation = { node: pick, cycle, neigh };
          }
          if (inCycle < AMB_GLOW) {
            ambAlpha = Math.sin((inCycle / AMB_GLOW) * Math.PI);   // 0 → 1 → 0 over 6 s
            ambNode = st.constellation.node;
            ambSet = st.constellation.neigh;
          }
        }
      }

      // === EDGES ===
      const sortedEdges = st.edges.slice().sort((a, b) =>
        (b.source.depth + b.target.depth) - (a.source.depth + a.target.depth)
      );
      const edgeFade = 1 - st.convergence;   // fade out as the chat orb forms
      const drawEdge = (e) => {
        ctx.beginPath();
        ctx.moveTo(e.source.px, e.source.py);
        ctx.lineTo(e.target.px, e.target.py);
        ctx.stroke();
      };
      if (edgeFade > 0.02 && focus) {
        // Only the focused node's links, bright.
        ctx.strokeStyle = "#bff5ff";
        ctx.lineWidth = 1.4;
        ctx.shadowColor = "#7ee0ff"; ctx.shadowBlur = 10;
        for (const e of sortedEdges) {
          if (e.source !== focus && e.target !== focus) continue;
          const depthAvg = (e.source.depth + e.target.depth) / 2;
          const depthAlpha = Math.max(0.3, Math.min(1, 1 - depthAvg / 1000));
          ctx.globalAlpha = 0.95 * depthAlpha * edgeFade;
          drawEdge(e);
        }
        ctx.shadowBlur = 0;
      } else if (edgeFade > 0.02 && ambNode && ambAlpha > 0.01) {
        // Only the ambient constellation's links — soft glow so it reads, but
        // gentler than the focus state.
        ctx.strokeStyle = "#bff0ff";
        ctx.lineWidth = 1.3;
        ctx.shadowColor = "#7ee0ff"; ctx.shadowBlur = 6;
        for (const e of sortedEdges) {
          if (e.source !== ambNode && e.target !== ambNode) continue;
          const depthAvg = (e.source.depth + e.target.depth) / 2;
          const depthAlpha = Math.max(0.3, Math.min(1, 1 - depthAvg / 1000));
          ctx.globalAlpha = 0.8 * ambAlpha * depthAlpha;
          drawEdge(e);
        }
        ctx.shadowBlur = 0;
      }
      ctx.shadowBlur = 0;
      ctx.globalAlpha = 1;

      // === NODES (z-sorted: far → near) ===
      // Separate the phenomenon (rendered as a special black-hole below/above based on depth)
      const phenomenonNode = st.nodes.find(n => n.type === "phenomenon");
      const orbitableNodes = st.nodes.filter(n => n.type !== "phenomenon");
      const sortedNodes = orbitableNodes.slice().sort((a, b) => b.depth - a.depth);

      // === FAINT ATTRACTION LINES toward the phenomenon (only for the focused
      // node's constellation — no permanent radial clutter in the default view) ===
      if (phenomenonNode && focus && st.convergence < 0.5) {
        ctx.globalAlpha = 0.10 * (1 - st.convergence * 2);
        ctx.strokeStyle = "#7ee0ff";
        ctx.lineWidth = 0.3;
        for (const n of orbitableNodes) {
          if (!connected.has(n.id) && n !== focus) continue;
          ctx.beginPath();
          ctx.moveTo(n.px, n.py);
          ctx.lineTo(phenomenonNode.px, phenomenonNode.py);
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
      }

      // === BLACK HOLE (the phenomenon at the galactic center) — draw FIRST so it sits behind orbs ===
      if (phenomenonNode && phenomenonNode.depth > 0) {
        drawBlackHole(ctx, phenomenonNode, focus === phenomenonNode, t);
      }

      for (const n of sortedNodes) {
        const isFocus = focus === n;
        const isHover = st.hover === n && st.hover !== selectedNode;
        const isConn = connected.has(n.id);
        const ambHit = ambSet && ambSet.has(n.id);   // in the active ambient constellation
        // Size: selected biggest, hovered intermediate, connections smaller, others normal
        const sizeMul = (isFocus ? 1.25 : (isHover ? 1.1 : (isConn ? 0.85 : 1)))
                        * (1 + (ambHit ? 0.16 * ambAlpha : 0));
        const r = Math.max(2.2, nodeRadius(n) * n.scale * sizeMul);
        let color = TYPE_COLORS[n.type] || "#fff";
        // During chat thinking: shift all colors toward cyan with a soft pulse
        if (st.convergence > 0.05) {
          const pulseAmt = st.chatMode === "thinking"
            ? 0.5 + 0.35 * Math.sin(st.pulse)
            : 0.4;
          color = blendColors(color, "#bff5ff", st.convergence * pulseAmt);
        }
        const dimmed = dim && !isConn && !isHover;
        const depthAlpha = Math.max(0.4, Math.min(1, 1 - n.depth / 1200));
        ctx.globalAlpha = (dimmed ? 0.18 : 1) * depthAlpha;

        // === OUTER GLOW (atmospheric halo) ===
        if (!dimmed) {
          const glowR = r * (isFocus ? 5.5 : (isConn ? 2.2 : (ambHit ? 3.6 : 2.8)));
          const glowAlpha = (isFocus ? 0.7 : (isConn ? 0.22 : 0.32)) + (ambHit ? 0.42 * ambAlpha : 0);
          const glowGrad = ctx.createRadialGradient(n.px, n.py, r * 0.6, n.px, n.py, glowR);
          glowGrad.addColorStop(0, hexA(color, glowAlpha));
          glowGrad.addColorStop(0.5, hexA(color, glowAlpha * 0.35));
          glowGrad.addColorStop(1, hexA(color, 0));
          ctx.fillStyle = glowGrad;
          ctx.beginPath();
          ctx.arc(n.px, n.py, glowR, 0, Math.PI * 2);
          ctx.fill();
        }

        // === ORB BODY (3D radial gradient) ===
        const hx = n.px - r * 0.35;
        const hy = n.py - r * 0.35;
        const bodyGrad = ctx.createRadialGradient(hx, hy, 0, n.px, n.py, r * 1.05);
        bodyGrad.addColorStop(0,   "rgba(255,255,255,0.95)");
        bodyGrad.addColorStop(0.18, hexA(lighten(color, 0.5), 0.95));
        bodyGrad.addColorStop(0.5,  hexA(color, 0.9));
        bodyGrad.addColorStop(0.85, hexA(darken(color, 0.4), 0.95));
        bodyGrad.addColorStop(1,   hexA(darken(color, 0.65), 1));
        ctx.fillStyle = bodyGrad;
        ctx.beginPath();
        ctx.arc(n.px, n.py, r, 0, Math.PI * 2);
        ctx.fill();

        // === RIM OUTLINE ===
        ctx.strokeStyle = isFocus ? "rgba(255,255,255,0.98)" : hexA(lighten(color, 0.5), 0.65);
        ctx.lineWidth = isFocus ? 2 : Math.max(0.5, r * 0.08);
        ctx.beginPath();
        ctx.arc(n.px, n.py, r, 0, Math.PI * 2);
        ctx.stroke();

        // === SPECULAR HIGHLIGHT ===
        if (r > 3) {
          ctx.fillStyle = "rgba(255,255,255,0.9)";
          ctx.beginPath();
          ctx.arc(hx, hy, Math.max(0.6, r * 0.18), 0, Math.PI * 2);
          ctx.fill();
        }

        // === FOCUS RING (outside the orb) ===
        if (isFocus) {
          ctx.strokeStyle = "rgba(255,255,255,0.95)";
          ctx.lineWidth = 1.6;
          ctx.beginPath();
          ctx.arc(n.px, n.py, r + 7, 0, Math.PI * 2);
          ctx.stroke();
          // Secondary softer ring for extra emphasis
          ctx.strokeStyle = hexA(color, 0.5);
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.arc(n.px, n.py, r + 11, 0, Math.PI * 2);
          ctx.stroke();
        }
      }
      ctx.globalAlpha = 1;

      // === BLACK HOLE in foreground if it's closer than camera-facing orbs ===
      if (phenomenonNode && phenomenonNode.depth <= 0) {
        drawBlackHole(ctx, phenomenonNode, focus === phenomenonNode, t);
      }

      // === LABELS (only for focus + connected, anti-collision) ===
      if (focus) {
        // Collect nodes to label: focus + connected, ordered by importance (focus first, then by degree)
        const labelCandidates = sortedNodes
          .filter(n => connected.has(n.id))
          .sort((a, b) => {
            if (a === focus) return -1;
            if (b === focus) return 1;
            return (b.deg || 0) - (a.deg || 0);
          })
          .slice(0, 16);

        // Compute label positions and resolve collisions
        const labels = [];
        for (const n of labelCandidates) {
          const r = nodeRadius(n) * n.scale;
          const labelText = window.nodeDisplayName ? window.nodeDisplayName(n) : n.name;
          const isFocus = focus === n;
          const size = isFocus ? 14 : 12;
          ctx.font = `${isFocus ? 600 : 500} ${size}px ui-sans-serif, system-ui, sans-serif`;
          const w = ctx.measureText(labelText).width;
          const h = size + 6;
          // Initial position: below the node
          let lx = n.px;
          let ly = n.py + r + 8;
          // Find a non-colliding position by scanning candidate offsets (below, above, right, left)
          const offsets = [
            { dx: 0, dy: r + 8, anchor: "center", vy: "top" },
            { dx: 0, dy: -(r + 8 + h), anchor: "center", vy: "top" },
            { dx: r + 8, dy: -h/2, anchor: "left", vy: "top" },
            { dx: -(r + 8), dy: -h/2, anchor: "right", vy: "top" }
          ];
          let placed = null;
          for (const off of offsets) {
            const cx = n.px + off.dx;
            const cy = n.py + off.dy;
            const box = labelBox(cx, cy, w, h, off.anchor);
            if (!labels.some(L => rectOverlap(L.box, box))) {
              placed = { x: cx, y: cy, w, h, box, anchor: off.anchor, text: labelText, isFocus, color: TYPE_COLORS[n.type], scale: n.scale, node: n };
              break;
            }
          }
          if (!placed) {
            // Last resort: shift down progressively until no collision
            let cy = n.py + r + 8;
            for (let tries = 0; tries < 12; tries++) {
              const box = labelBox(n.px, cy, w, h, "center");
              if (!labels.some(L => rectOverlap(L.box, box))) {
                placed = { x: n.px, y: cy, w, h, box, anchor: "center", text: labelText, isFocus, color: TYPE_COLORS[n.type], scale: n.scale, node: n };
                break;
              }
              cy += h + 2;
            }
          }
          if (placed) labels.push(placed);
        }

        // Draw labels (and store hit boxes for click/hover)
        st.labelHits = [];
        for (const L of labels) {
          const isHoverLbl = st.hover === L.node && st.hover !== selectedNode;
          const fsize = L.isFocus ? 14 : (isHoverLbl ? 12.5 : 11);
          const fweight = L.isFocus ? 600 : (isHoverLbl ? 600 : 500);
          ctx.font = `${fweight} ${fsize}px ui-sans-serif, system-ui, sans-serif`;
          const padX = L.isFocus ? 7 : (isHoverLbl ? 6 : 5);
          const padY = L.isFocus ? 4 : (isHoverLbl ? 3.5 : 2.5);
          const boxX = L.box.x, boxY = L.box.y;
          // background pill
          ctx.fillStyle = L.isFocus ? "rgba(8,16,28,0.96)" : (isHoverLbl ? "rgba(8,16,28,0.92)" : "rgba(8,16,28,0.78)");
          roundRect(ctx, boxX - padX, boxY - padY, L.w + padX * 2, L.h + padY * 2, L.isFocus ? 4 : 3);
          ctx.fill();
          // border
          ctx.strokeStyle = L.isFocus
            ? hexA(L.color, 0.9)
            : (isHoverLbl ? hexA(L.color, 0.65) : "rgba(126,224,255,0.14)");
          ctx.lineWidth = L.isFocus ? 1 : (isHoverLbl ? 0.9 : 0.5);
          roundRect(ctx, boxX - padX, boxY - padY, L.w + padX * 2, L.h + padY * 2, L.isFocus ? 4 : 3);
          ctx.stroke();
          // text
          ctx.fillStyle = L.isFocus ? "#ffffff" : (isHoverLbl ? "rgba(255,255,255,0.95)" : "rgba(226,242,255,0.72)");
          ctx.textAlign = L.anchor;
          ctx.textBaseline = "top";
          ctx.fillText(L.text, L.x, L.y);
          // Record hit box (for label clicks)
          st.labelHits.push({
            x: boxX - padX, y: boxY - padY,
            w: L.w + padX * 2, h: L.h + padY * 2,
            node: L.node
          });
        }
      } else {
        st.labelHits = [];
      }

      ctx.restore && ctx.restore();
      st.raf = requestAnimationFrame(step);
    };
    st.raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(st.raf);
  }, [data, filters, selectedId, tweaks]);

  // ============== MOUSE/TOUCH HANDLERS ==============
  React.useEffect(() => {
    const cv = canvasRef.current;
    const st = stateRef.current;

    const pickNode = (mx, my) => {
      // First check label hits (labels take priority over orbs behind them)
      const hits = st.labelHits || [];
      for (const L of hits) {
        if (mx >= L.x && mx <= L.x + L.w && my >= L.y && my <= L.y + L.h) {
          return L.node;
        }
      }
      let best = null, bestD = Infinity;
      for (const n of st.nodes) {
        const r = nodeRadius(n) * n.scale + 6;
        const dx = n.px - mx, dy = n.py - my;
        const d = dx*dx + dy*dy;
        if (d < r*r && d < bestD) { best = n; bestD = d; }
      }
      return best;
    };

    const onMove = (e) => {
      const rect = cv.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      // Any movement marks interaction
      if (st.pending || st.drag || st.rotate || st.panDrag) st.lastInteraction = performance.now();
      // Promote pending to drag on movement
      if (st.pending) {
        const d = Math.hypot(mx - st.pending.mx, my - st.pending.my);
        if (d >= 6) {
          st.drag = st.pending.node;
          st.pending = null;
          cv.style.cursor = "grabbing";
        }
      }
      if (st.drag) {
        // Drag node: convert screen delta to world; simplest approach: keep node at cursor in projected XY
        // We adjust node's world position so its projection lands at (mx, my).
        // Approximate by setting screen-aligned drag in inverse rotation space.
        // For simplicity, only modify node's (x, z) along screen axes ignoring perspective scale.
        const target = unprojectScreen(mx - st.pan.x, my - st.pan.y, st.drag.depth, st);
        st.drag.x = target.x; st.drag.y = target.y; st.drag.z = target.z;
        st.drag.vx = st.drag.vy = st.drag.vz = 0;
        st.alpha = Math.max(st.alpha, 0.2);
      } else if (st.rotate) {
        const dx = mx - st.rotate.mx;
        const dy = my - st.rotate.my;
        // Yaw with horizontal drag, pitch with vertical
        st.rotY = st.rotate.startRotY + dx * 0.008;
        st.rotX = clamp(st.rotate.startRotX + dy * 0.008, -Math.PI/2 + 0.05, Math.PI/2 - 0.05);
      } else if (st.panDrag) {
        st.pan.x = st.panDrag.startPanX + (mx - st.panDrag.mx);
        st.pan.y = st.panDrag.startPanY + (my - st.panDrag.my);
      } else {
        st.hover = pickNode(mx, my);
        cv.style.cursor = st.hover ? "pointer" : "grab";
      }
    };
    const onDown = (e) => {
      st.lastInteraction = performance.now();
      const rect = cv.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      const h = pickNode(mx, my);
      if (h) {
        st.pending = { node: h, mx, my };
        cv.style.cursor = "grabbing";
      } else {
        // Background drag: shift+drag → pan, plain drag → rotate
        if (e.shiftKey) {
          st.panDrag = { mx, my, startPanX: st.pan.x, startPanY: st.pan.y };
        } else {
          st.rotate = { mx, my, startRotX: st.rotX, startRotY: st.rotY, active: false, lastMx: mx, lastMy: my };
        }
        cv.style.cursor = "grabbing";
      }
    };
    const onUp = () => {
      // Pending without movement → click select
      if (st.pending) {
        onSelect(st.pending.node.id);
        st.pending = null;
      } else if (st.drag) {
        st.drag.vx = st.drag.vy = st.drag.vz = 0;
        st.drag = null;
      } else if (st.rotate) {
        // If rotation was tiny, treat as click on background → deselect
        const movedRotX = Math.abs(st.rotX - st.rotate.startRotX);
        const movedRotY = Math.abs(st.rotY - st.rotate.startRotY);
        if (movedRotX < 0.005 && movedRotY < 0.005) onSelect(null);
        st.rotate = null;
      } else if (st.panDrag) {
        st.panDrag = null;
      }
      cv.style.cursor = st.hover ? "pointer" : "grab";
    };
    const onLeave = () => {
      st.pending = null;
      st.drag = null;
      st.rotate = null;
      st.panDrag = null;
      st.hover = null;
      cv.style.cursor = "grab";
    };
    const onWheel = (e) => {
      e.preventDefault();
      st.lastInteraction = performance.now();
      const factor = e.deltaY > 0 ? 0.88 : 1.14;
      const newZ = clamp(st.zoom * factor, 0.5, 6);
      st.zoom = newZ;
    };

    cv.addEventListener("mousemove", onMove);
    cv.addEventListener("mousedown", onDown);
    window.addEventListener("mouseup", onUp);
    cv.addEventListener("mouseleave", onLeave);
    cv.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      cv.removeEventListener("mousemove", onMove);
      cv.removeEventListener("mousedown", onDown);
      window.removeEventListener("mouseup", onUp);
      cv.removeEventListener("mouseleave", onLeave);
      cv.removeEventListener("wheel", onWheel);
    };
  }, [onSelect]);

  // ============== EXPOSE API for zoom controls ==============
  React.useEffect(() => {
    const st = stateRef.current;
    window.__rdcGraph = {
      // 1.0 internal = 100% UI baseline (galaxy fits the viewport at load)
      zoomBy: (f) => { st.zoom = clamp(st.zoom * f, 0.4, 6); },
      reset: () => {
        st.zoom = 1.0;
        st.rotX = 0.68;
        st.rotY = 0;
        st.pan = { x: 0, y: 0 };
        st.alpha = Math.max(st.alpha, 0.4);
      },
      // Display: 1.0 internal => 100% display
      getZoom: () => st.zoom / 1.0
    };
  }, []);

  return (
    <div ref={containerRef} style={{position:"absolute", inset:0, overflow:"hidden", background:"radial-gradient(ellipse at center, #0a1830 0%, #04080f 80%)"}}>
      <canvas ref={canvasRef} style={{display:"block"}} />
      <div className="graph-hint">
        Arrastra · Rotar &nbsp;·&nbsp; Shift+Arrastra · Mover &nbsp;·&nbsp; Scroll · Zoom &nbsp;·&nbsp; Click · Ficha
      </div>
    </div>
  );
}

// ===================================================================
// HELPERS
// ===================================================================

function projectPoint(x, y, z, st) {
  // Galaxy intrinsic rotation: spin world around Y axis before camera transform
  const gr = st.galaxyRot || 0;
  const cosG = Math.cos(gr), sinG = Math.sin(gr);
  const gx = x * cosG + z * sinG;
  const gz = -x * sinG + z * cosG;
  // Camera rotation around origin (Y then X)
  const cosY = Math.cos(st.rotY), sinY = Math.sin(st.rotY);
  const cosX = Math.cos(st.rotX), sinX = Math.sin(st.rotX);
  const x1 = gx * cosY + gz * sinY;
  const z1 = -gx * sinY + gz * cosY;
  const y1 = y;
  const y2 = y1 * cosX - z1 * sinX;
  const z2 = y1 * sinX + z1 * cosX;
  // Perspective
  const fov = 800;
  const depth = fov + z2;
  const scale = (fov / depth) * st.zoom;
  return {
    px: st.size.w / 2 + x1 * scale + st.pan.x,
    py: st.size.h / 2 + y2 * scale + st.pan.y,
    scale,
    depth: z2
  };
}

function unprojectScreen(screenX, screenY, depth, st) {
  // Inverse of projectPoint, including galaxy rotation
  const fov = 800;
  const persp = fov / (fov + depth);
  const scale = persp * st.zoom;
  const x1 = (screenX - st.size.w / 2) / scale;
  const y2 = (screenY - st.size.h / 2) / scale;
  const z2 = depth;
  const cosX = Math.cos(st.rotX), sinX = Math.sin(st.rotX);
  const y1 = y2 * cosX + z2 * sinX;
  const z1 = -y2 * sinX + z2 * cosX;
  const cosY = Math.cos(st.rotY), sinY = Math.sin(st.rotY);
  const gx = x1 * cosY - z1 * sinY;
  const gz = x1 * sinY + z1 * cosY;
  // Inverse of galaxy rotation
  const gr = st.galaxyRot || 0;
  const cosG = Math.cos(gr), sinG = Math.sin(gr);
  const x = gx * cosG - gz * sinG;
  const z = gx * sinG + gz * cosG;
  return { x, y: y1, z };
}

function drawGalaxyBackdrop(ctx, st) {
  const minDim = Math.min(st.size.w, st.size.h);
  // Disk rings (in XZ plane, y=0)
  const ringRadii = [minDim * 0.12, minDim * 0.22, minDim * 0.30, minDim * 0.42];
  for (const r of ringRadii) {
    drawProjectedCircle(ctx, r, st, "rgba(126,224,255,0.055)", 1);
  }
  // Central glow
  const center = projectPoint(0, 0, 0, st);
  const coreR = minDim * 0.4 * center.scale;
  const grad = ctx.createRadialGradient(center.px, center.py, 0, center.px, center.py, coreR);
  grad.addColorStop(0, "rgba(126,224,255,0.08)");
  grad.addColorStop(0.35, "rgba(126,224,255,0.03)");
  grad.addColorStop(1, "rgba(126,224,255,0)");
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(center.px, center.py, coreR, 0, Math.PI*2);
  ctx.fill();
  // Two faint spiral-arm guides matching the node layout (NUM_ARMS=2,
  // ARM_WIND≈3.1) so the grand-design spiral reads clearly.
  const NUM_ARMS = 2;
  const TWIST = 3.1;
  const maxR = minDim * 0.46;
  const armR0 = minDim * 0.08;
  const armR1 = minDim * 0.46;
  ctx.strokeStyle = "rgba(126,224,255,0.06)";
  ctx.lineWidth = 1.5;
  for (let i = 0; i < NUM_ARMS; i++) {
    const baseA = i * Math.PI;
    ctx.beginPath();
    let first = true;
    for (let t = 0; t <= 1.001; t += 0.03) {
      const r = armR0 + (armR1 - armR0) * t;
      const a = baseA + (r / maxR) * TWIST;
      const p = projectPoint(Math.cos(a) * r, 0, Math.sin(a) * r, st);
      if (first) { ctx.moveTo(p.px, p.py); first = false; }
      else ctx.lineTo(p.px, p.py);
    }
    ctx.stroke();
  }
}

function drawProjectedCircle(ctx, r, st, color, width) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.beginPath();
  let first = true;
  const SEGMENTS = 80;
  for (let i = 0; i <= SEGMENTS; i++) {
    const a = (i / SEGMENTS) * Math.PI * 2;
    const p = projectPoint(Math.cos(a) * r, 0, Math.sin(a) * r, st);
    if (first) { ctx.moveTo(p.px, p.py); first = false; }
    else ctx.lineTo(p.px, p.py);
  }
  ctx.stroke();
}

function nodeRadius(n) {
  if (n.type === "phenomenon") return 18; // large enough to anchor, but specifically rendered
  const base = { person: 4, agency: 5, event: 4.5, program: 4.5, concept: 4, channel: 6 }[n.type] || 4;
  // Exponential compression: small nodes barely shrink, large nodes shrink much more.
  // Asymptotic cap lowered so the most-connected nodes don't dominate the disc.
  const deg = n.deg || 0;
  return base + (1 - Math.exp(-deg / 18)) * 4.5;
}

// Render the central black hole (the phenomenon).
// Drawn as a dark disc with a luminous accretion ring rotating slowly.
function drawBlackHole(ctx, n, isFocus, t) {
  const r = 22 * n.scale;
  const px = n.px, py = n.py;
  const phase = (t * 0.0002) % (Math.PI * 2);

  // Outer glow (lensing halo)
  const glowR = r * 5;
  const glow = ctx.createRadialGradient(px, py, r * 0.5, px, py, glowR);
  glow.addColorStop(0, "rgba(126,224,255,0.22)");
  glow.addColorStop(0.4, "rgba(126,224,255,0.08)");
  glow.addColorStop(1, "rgba(126,224,255,0)");
  ctx.fillStyle = glow;
  ctx.beginPath();
  ctx.arc(px, py, glowR, 0, Math.PI * 2);
  ctx.fill();

  // Accretion ring (luminous, asymmetric — brighter on one side)
  const ringR = r * 1.55;
  const ringW = r * 0.35;
  for (let i = 0; i < 64; i++) {
    const a0 = (i / 64) * Math.PI * 2 + phase;
    const a1 = ((i + 1) / 64) * Math.PI * 2 + phase;
    // Doppler-style brightness: brighter at the bottom half (closer side)
    const bright = 0.4 + 0.6 * Math.sin(a0 + 0.5);
    const alpha = isFocus ? 0.95 * bright : 0.7 * bright;
    ctx.strokeStyle = `rgba(${Math.round(180 + bright*75)}, ${Math.round(230 + bright*25)}, 255, ${alpha})`;
    ctx.lineWidth = ringW;
    ctx.beginPath();
    ctx.arc(px, py, ringR, a0, a1);
    ctx.stroke();
  }

  // Event horizon (pitch black disc)
  ctx.fillStyle = "#000000";
  ctx.beginPath();
  ctx.arc(px, py, r, 0, Math.PI * 2);
  ctx.fill();
  // Sub-thin event horizon rim (Doppler-bright sliver)
  ctx.strokeStyle = isFocus ? "rgba(255,255,255,0.95)" : "rgba(126,224,255,0.7)";
  ctx.lineWidth = isFocus ? 1.6 : 1;
  ctx.beginPath();
  ctx.arc(px, py, r, 0, Math.PI * 2);
  ctx.stroke();

  // Focus ring when selected
  if (isFocus) {
    ctx.strokeStyle = "rgba(255,255,255,0.85)";
    ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.arc(px, py, r + 10, 0, Math.PI * 2);
    ctx.stroke();
  }
}

function hexA(hex, a) {
  const h = hex.replace("#", "");
  const r = parseInt(h.substring(0,2), 16);
  const g = parseInt(h.substring(2,4), 16);
  const b = parseInt(h.substring(4,6), 16);
  return `rgba(${r},${g},${b},${a})`;
}

function lighten(hex, amount) {
  const h = hex.replace("#", "");
  const r = parseInt(h.substring(0,2), 16);
  const g = parseInt(h.substring(2,4), 16);
  const b = parseInt(h.substring(4,6), 16);
  const nr = Math.round(r + (255 - r) * amount);
  const ng = Math.round(g + (255 - g) * amount);
  const nb = Math.round(b + (255 - b) * amount);
  return "#" + [nr, ng, nb].map(v => v.toString(16).padStart(2, "0")).join("");
}

function darken(hex, amount) {
  const h = hex.replace("#", "");
  const r = parseInt(h.substring(0,2), 16);
  const g = parseInt(h.substring(2,4), 16);
  const b = parseInt(h.substring(4,6), 16);
  const nr = Math.round(r * (1 - amount));
  const ng = Math.round(g * (1 - amount));
  const nb = Math.round(b * (1 - amount));
  return "#" + [nr, ng, nb].map(v => v.toString(16).padStart(2, "0")).join("");
}

function blendColors(a, b, t) {
  t = Math.max(0, Math.min(1, t));
  const pa = parseHex(a), pb = parseHex(b);
  const r = Math.round(pa.r + (pb.r - pa.r) * t);
  const g = Math.round(pa.g + (pb.g - pa.g) * t);
  const bl = Math.round(pa.b + (pb.b - pa.b) * t);
  return "#" + [r, g, bl].map(v => v.toString(16).padStart(2, "0")).join("");
}
function parseHex(hex) {
  const h = hex.replace("#", "");
  return {
    r: parseInt(h.substring(0,2), 16),
    g: parseInt(h.substring(2,4), 16),
    b: parseInt(h.substring(4,6), 16)
  };
}

function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }

function labelBox(cx, cy, w, h, anchor) {
  let x = cx;
  if (anchor === "center") x = cx - w/2;
  else if (anchor === "right") x = cx - w;
  return { x, y: cy, w, h };
}

function rectOverlap(a, b) {
  return !(a.x + a.w + 6 < b.x || b.x + b.w + 6 < a.x || a.y + a.h + 4 < b.y || b.y + b.h + 4 < a.y);
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

window.NetworkGraph = NetworkGraph;
window.TYPE_COLORS = TYPE_COLORS;
window.TYPE_LABEL_ES = TYPE_LABEL_ES;
