// ==== The Atlas — cinematic-at-rest, readable-on-focus knowledge graph =
// Direction C. Self at centre; PROJECTS form spokes; each project's
// evidence (papers / talks / awards) fans out beyond it; SKILLS sit on a
// lower substrate plane. Static layout (rock-solid clicking) + living
// camera, glow and pulses. Hover = trace neighbours. Click = Focus mode:
// camera frames the cluster, a connection rail lets you WALK the graph,
// arrow-keys step between siblings, Esc exits.

// ---- palette (on-brand: warm-dominant, two muted cools, no rainbow) ----
const ATLAS_COLOR = {
  self:    0xECEAE2,
  project: 0xE2BE82,
  paper:   0xB8A6CE,
  talk:    0x9FB8C4,
  award:   0xD9A24E,
  skill:   0x8A8780,
};
const ATLAS_HEX = {
  self: "#ECEAE2", project: "#E2BE82", paper: "#B8A6CE",
  talk: "#9FB8C4", award: "#D9A24E", skill: "#8A8780",
};

// ---- build graph model (nodes + typed edges) -------------------------
function buildAtlas() {
  const nodes = [];
  const N = (o) => { nodes.push(o); return o.id; };

  N({ id: "self", label: "AYUSH", kind: "self", weight: 1.7, meta: null });

  AYUSH.PROJECTS.forEach(p => N({ id: `p:${p.id}`, label: p.name, kind: "project", weight: p.size === "large" ? 1.2 : p.size === "small" ? 0.82 : 1.0, meta: p }));
  AYUSH.PAPERS.forEach((p, i) => N({ id: `pa:${i}`, label: p.title.split(/[—:·]/)[0].trim(), kind: "paper", weight: 0.7, meta: p }));
  AYUSH.TALKS.forEach((t, i) => N({ id: `tk:${i}`, label: t.title.split(/[—:·]/)[0].trim(), kind: "talk", weight: 0.66, meta: t }));
  AYUSH.AWARDS.slice(0, 6).forEach((a, i) => N({ id: `aw:${i}`, label: a.name, kind: "award", weight: 0.64, meta: a }));
  AYUSH.SKILLS.forEach((s, i) => N({ id: `s:${i}`, label: s.group, kind: "skill", weight: 0.7, meta: s }));

  const has = new Set(nodes.map(n => n.id));
  const edges = [];
  const E = (a, b, rel) => { if (has.has(a) && has.has(b)) edges.push({ a, b, rel }); };

  // self → projects (structural spine)
  AYUSH.PROJECTS.forEach(p => E("self", `p:${p.id}`, "root"));

  // project → evidence (the bright story chains)
  E("p:mantis", "pa:1", "evidence");        // Autonomous Adversarial Threat Detection
  E("p:mantis", "tk:0", "evidence");        // BSides talk
  E("p:mantis", "aw:1", "evidence");        // NullCon AI Paper of the Year
  E("p:agent-forensics", "pa:0", "evidence"); // Tamper-Evident ≠ Trustworthy
  E("p:cyberway", "pa:2", "evidence");       // CyberWay standard
  E("p:mec", "pa:3", "evidence");            // 5G MEC paper
  E("p:mec", "aw:3", "evidence");            // Samsung Cert of Excellence

  // self → unattached talks / awards
  E("self", "aw:0", "root");  // Rising Star
  E("self", "aw:2", "root");  // LiFT Scholar
  E("self", "aw:4", "root");  // AWS DeepRacer
  E("self", "aw:5", "root");  // HPAIR
  E("self", "tk:1", "root");  // HPAIR talk
  E("self", "tk:2", "root");  // OSS Summit
  E("self", "tk:3", "root");  // TechBharat

  // project → skill substrate
  E("p:mantis", "s:1", "skill"); E("p:mantis", "s:4", "skill"); E("p:mantis", "s:2", "skill");
  E("p:aegis", "s:1", "skill");  E("p:aegis", "s:4", "skill");  E("p:aegis", "s:5", "skill");
  E("p:agentbridge", "s:1", "skill"); E("p:agentbridge", "s:5", "skill");
  E("p:cyberway", "s:4", "skill");
  E("p:agent-forensics", "s:4", "skill"); E("p:agent-forensics", "s:1", "skill");
  E("p:superagi", "s:1", "skill"); E("p:superagi", "s:0", "skill");
  E("p:mec", "s:2", "skill");

  // adjacency (semantic neighbours for the connection rail / traversal)
  const adj = new Map(nodes.map(n => [n.id, new Set()]));
  edges.forEach(e => { adj.get(e.a).add(e.b); adj.get(e.b).add(e.a); });

  return { nodes, edges, adj, byId: new Map(nodes.map(n => [n.id, n])) };
}

// ---- static spoke-cluster layout -------------------------------------
function layoutAtlas(model) {
  const { nodes, edges, byId } = model;
  const V = THREE.Vector3;
  const self = byId.get("self");
  self.pos = new V(0, 0, 0);

  const projects = nodes.filter(n => n.kind === "project");
  const nP = projects.length;

  // children of each project (evidence + skill), and self-attached extras
  const childrenOf = new Map(projects.map(p => [p.id, []]));
  const selfExtras = [];
  edges.forEach(e => {
    if (e.a.startsWith("p:") && (e.rel === "evidence" || e.rel === "skill")) childrenOf.get(e.a)?.push(e.b);
    if (e.a === "self" && e.b !== "self" && !e.b.startsWith("p:")) selfExtras.push(e.b);
  });

  const RP = 9.2;            // project ring radius
  projects.forEach((p, i) => {
    const ang = (i / nP) * Math.PI * 2 - Math.PI / 2;
    p._ang = ang;
    p.pos = new V(Math.cos(ang) * RP, Math.sin(i * 1.3) * 0.6, Math.sin(ang) * RP);

    // fan this project's children outward along its radial direction
    const kids = (childrenOf.get(p.id) || []).map(id => byId.get(id)).filter(Boolean);
    const dir = new V(Math.cos(ang), 0, Math.sin(ang));
    const tangent = new V(-Math.sin(ang), 0, Math.cos(ang));
    kids.forEach((k, j) => {
      const spread = (j - (kids.length - 1) / 2);
      const out = RP + 4.6 + (k.kind === "skill" ? 2.2 : 0);
      const lateral = spread * 2.5;
      const yPlane = k.kind === "skill" ? -2.6 : (k.kind === "paper" ? 1.7 : 1.0);
      // only set if not already placed (a child may link to multiple projects → keep first)
      if (!k.pos) {
        k.pos = new V(
          dir.x * out + tangent.x * lateral,
          yPlane + Math.sin(j * 1.9) * 0.4,
          dir.z * out + tangent.z * lateral
        );
        k._parent = p.id;
      }
    });
  });

  // self-attached talks/awards: inner ring below the plane, near centre
  selfExtras.map(id => byId.get(id)).filter(n => n && !n.pos).forEach((n, j, arr) => {
    const ang = (j / arr.length) * Math.PI * 2 + 0.4;
    const r = 4.7;
    n.pos = new V(Math.cos(ang) * r, -3.4 + Math.sin(j) * 0.3, Math.sin(ang) * r);
    n._parent = "self";
  });

  // any stragglers (unlinked) → outer ring
  let s = 0;
  nodes.forEach(n => {
    if (!n.pos) {
      const ang = (s++ / 6) * Math.PI * 2;
      n.pos = new V(Math.cos(ang) * 16, 2.5, Math.sin(ang) * 16);
    }
  });
}

// ---- soft round sprite (glow) ----------------------------------------
function atlasGlowTex() {
  const s = 128, c = document.createElement("canvas"); c.width = c.height = s;
  const x = c.getContext("2d");
  const g = x.createRadialGradient(s/2, s/2, 0, s/2, s/2, s/2);
  g.addColorStop(0, "rgba(255,255,255,1)");
  g.addColorStop(0.25, "rgba(255,255,255,0.55)");
  g.addColorStop(0.5, "rgba(255,255,255,0.14)");
  g.addColorStop(1, "rgba(255,255,255,0)");
  x.fillStyle = g; x.fillRect(0, 0, s, s);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}

function ForceGraph3D() {
  const wrapRef = useRef(null);
  const labelsRef = useRef(null);
  const apiRef = useRef(null);
  const [selected, setSelected] = useState(null);   // node in focus (or null = ambient)
  const [neighbors, setNeighbors] = useState([]);    // for the connection rail
  const [filter, setFilter] = useState(() => new Set()); // active kind filters (empty = all)
  const filterRef = useRef(filter);
  filterRef.current = filter;

  useEffect(() => {
    if (!window.THREE) return;
    const wrap = wrapRef.current;
    if (!wrap) return;

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
    renderer.setClearColor(0x000000, 0);
    wrap.appendChild(renderer.domElement);
    renderer.domElement.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;";

    const scene = new THREE.Scene();
    scene.fog = new THREE.Fog(0x07090e, 26, 64);

    const camera = new THREE.PerspectiveCamera(46, 1, 0.1, 200);
    camera.position.set(0, 6, 34);
    const lookAt = new THREE.Vector3(0, 0, 0);
    const lookTarget = new THREE.Vector3(0, 0, 0);

    const model = buildAtlas();
    layoutAtlas(model);
    const { nodes, edges, adj, byId } = model;
    const glowTex = atlasGlowTex();

    const root = new THREE.Group();
    scene.add(root);

    // ----- node meshes -----
    function geomFor(n) {
      const r = n.weight;
      switch (n.kind) {
        case "self":    return new THREE.IcosahedronGeometry(r * 0.7, 1);
        case "project": return new THREE.SphereGeometry(r * 0.5, 24, 24);
        case "paper":   return new THREE.OctahedronGeometry(r * 0.62, 0);
        case "talk":    return new THREE.ConeGeometry(r * 0.5, r * 0.9, 4);
        case "award":   return new THREE.TorusGeometry(r * 0.42, r * 0.16, 10, 22);
        default:        return new THREE.SphereGeometry(r * 0.34, 16, 16);
      }
    }
    nodes.forEach(n => {
      const col = ATLAS_COLOR[n.kind];
      const g = new THREE.Group();
      g.position.copy(n.pos);

      // crisp core
      const core = new THREE.Mesh(geomFor(n), new THREE.MeshBasicMaterial({ color: col }));
      g.add(core);

      // restrained glow (no blowout)
      const glow = new THREE.Sprite(new THREE.SpriteMaterial({
        map: glowTex, color: col, transparent: true,
        opacity: n.kind === "self" ? 0.6 : 0.5,
        blending: THREE.AdditiveBlending, depthWrite: false,
      }));
      const gs = n.weight * (n.kind === "self" ? 6.5 : 3.6);
      glow.scale.set(gs, gs, 1);
      g.add(glow);

      // project orbital ring
      if (n.kind === "project") {
        const ring = new THREE.Mesh(
          new THREE.RingGeometry(n.weight * 0.78, n.weight * 0.86, 40),
          new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.5, side: THREE.DoubleSide })
        );
        g.add(ring); n._ring = ring;
      }

      root.add(g);
      n._g = g; n._core = core; n._glow = glow;
      n._baseGlow = glow.material.opacity;
      n._lit = 1;            // animated 0..1 emphasis
      n._litTarget = 1;
    });

    // ----- edges (curved tubes-as-lines) -----
    const edgeObjs = edges.map(e => {
      const a = byId.get(e.a).pos, b = byId.get(e.b).pos;
      const mid = a.clone().add(b).multiplyScalar(0.5);
      mid.add(mid.clone().setY(0).normalize().multiplyScalar(1.8)); // bow outward
      mid.y += 0.6;
      const curve = new THREE.QuadraticBezierCurve3(a.clone(), mid, b.clone());
      const pts = curve.getPoints(24);
      const geom = new THREE.BufferGeometry().setFromPoints(pts);
      const col = e.rel === "evidence" ? ATLAS_COLOR.project
                : e.rel === "skill" ? 0x6f6a60
                : 0x3a4150;
      const base = e.rel === "evidence" ? 0.42 : e.rel === "skill" ? 0.16 : 0.09;
      const mat = new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: base, blending: THREE.AdditiveBlending, depthWrite: false });
      const line = new THREE.Line(geom, mat);
      root.add(line);
      return { e, line, mat, base, curve, aId: e.a, bId: e.b };
    });

    // ----- pulses along evidence edges -----
    const pulseEdges = edgeObjs.filter(eo => eo.e.rel === "evidence");
    const PULSE_N = Math.min(18, pulseEdges.length * 2);
    const pulseGeom = new THREE.BufferGeometry();
    const pPos = new Float32Array(PULSE_N * 3);
    const pCol = new Float32Array(PULSE_N * 3);
    const pulses = [];
    for (let i = 0; i < PULSE_N; i++) {
      const eo = pulseEdges[i % pulseEdges.length];
      pulses.push({ eo, t: Math.random(), speed: 0.3 + Math.random() * 0.4 });
      const c = new THREE.Color(ATLAS_COLOR.project);
      pCol[i*3] = c.r; pCol[i*3+1] = c.g; pCol[i*3+2] = c.b;
    }
    pulseGeom.setAttribute("position", new THREE.BufferAttribute(pPos, 3));
    pulseGeom.setAttribute("color", new THREE.BufferAttribute(pCol, 3));
    const pulseMat = new THREE.ShaderMaterial({
      uniforms: { uTex: { value: glowTex }, uPx: { value: renderer.getPixelRatio() } },
      transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexColors: true,
      vertexShader: `varying vec3 vC; void main(){ vC=color; vec4 mv=modelViewMatrix*vec4(position,1.0); gl_PointSize=(150.0/-mv.z); gl_Position=projectionMatrix*mv; }`,
      fragmentShader: `uniform sampler2D uTex; varying vec3 vC; void main(){ vec4 t=texture2D(uTex,gl_PointCoord); gl_FragColor=vec4(vC,1.0)*t; }`,
    });
    const pulsePts = new THREE.Points(pulseGeom, pulseMat);
    scene.add(pulsePts);

    // ----- starfield -----
    {
      const M = 700, sp = new Float32Array(M*3);
      for (let i=0;i<M;i++){ const a=Math.random()*Math.PI*2, r=42+Math.random()*55, ph=(Math.random()-0.5)*Math.PI;
        sp[i*3]=Math.cos(a)*r*Math.cos(ph); sp[i*3+1]=Math.sin(ph)*r; sp[i*3+2]=Math.sin(a)*r*Math.cos(ph); }
      const sg = new THREE.BufferGeometry(); sg.setAttribute("position", new THREE.BufferAttribute(sp,3));
      scene.add(new THREE.Points(sg, new THREE.PointsMaterial({ color: 0xECEAE2, size: 0.05, transparent: true, opacity: 0.4, blending: THREE.AdditiveBlending })));
    }

    // ----- DOM labels -----
    const labelEls = new Map();
    const labelsHost = labelsRef.current;
    nodes.forEach(n => {
      const el = document.createElement("div");
      el.className = "glabel k-" + n.kind;
      el.innerHTML = `<span class="d" style="background:${ATLAS_HEX[n.kind]}"></span><span class="t">${n.label}</span>`;
      el.addEventListener("pointerdown", (ev) => { ev.stopPropagation(); });
      el.addEventListener("click", (ev) => { ev.stopPropagation(); api.focus(n.id); });
      labelsHost.appendChild(el);
      labelEls.set(n.id, el);
    });

    // ----- orbit / camera state -----
    const orbit = {
      az: 0.5, el: 0.28, dist: 34,
      tAz: 0.5, tEl: 0.28, tDist: 34,
      drag: null, cur: { x: 0, y: 0 }, spin: true,
    };

    // ----- interaction -----
    const raycaster = new THREE.Raycaster();
    let hoverId = null;
    let focusId = null;

    function rc(e){ const r = renderer.domElement.getBoundingClientRect(); return { x: e.clientX-r.left, y: e.clientY-r.top, w: r.width, h: r.height }; }
    function pick(px, py, w, h) {
      const ndc = new THREE.Vector2((px/w)*2-1, -((py/h)*2-1));
      raycaster.setFromCamera(ndc, camera);
      const hits = raycaster.intersectObjects(nodes.map(n => n._core), false);
      if (hits.length) return nodes.find(n => n._core === hits[0].object);
      // screen-space fallback
      const tmp = new THREE.Vector3(); let best=null, bd=Infinity;
      for (const n of nodes) {
        n._g.getWorldPosition(tmp); const p = tmp.clone().project(camera);
        if (p.z > 1) continue;
        const sx=(p.x*0.5+0.5)*w, sy=(-p.y*0.5+0.5)*h;
        const d2=(sx-px)**2+(sy-py)**2, rad=n.weight*18;
        if (d2 < rad*rad && d2 < bd) { best=n; bd=d2; }
      }
      return best;
    }

    function neighborsOf(id) { return [...(adj.get(id) || [])].map(x => byId.get(x)).filter(Boolean); }

    function down(e){
      const {x,y,w,h}=rc(e); const hit=pick(x,y,w,h);
      orbit._click = hit ? { x:e.clientX, y:e.clientY, hit } : null;
      orbit.drag = { x:e.clientX, y:e.clientY, az:orbit.tAz, el:orbit.tEl };
      renderer.domElement.style.cursor="grabbing";
    }
    function move(e){
      if (orbit.drag) {
        const dx=e.clientX-orbit.drag.x, dy=e.clientY-orbit.drag.y;
        orbit.tAz = orbit.drag.az - dx*0.006;
        orbit.tEl = Math.max(-0.35, Math.min(0.95, orbit.drag.el + dy*0.006));
        if (orbit._click && (Math.abs(e.clientX-orbit._click.x)+Math.abs(e.clientY-orbit._click.y) > 6)) orbit._click=null;
        return;
      }
      const {x,y,w,h}=rc(e);
      orbit.cur.x=(x/w)*2-1; orbit.cur.y=(y/h)*2-1;
      const hit=pick(x,y,w,h);
      hoverId = hit ? hit.id : null;
      renderer.domElement.style.cursor = hit ? "pointer" : "grab";
    }
    function up(){
      if (orbit._click) { api.focus(orbit._click.hit.id); orbit._click=null; }
      orbit.drag=null; renderer.domElement.style.cursor="grab";
    }
    function wheel(e){
      if (focusId == null && !e.shiftKey) return;   // ambient: let the page scroll
      e.preventDefault();
      orbit.tDist = Math.max(10, Math.min(58, orbit.tDist * Math.exp(e.deltaY*0.001)));
    }
    renderer.domElement.addEventListener("pointerdown", down);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    renderer.domElement.addEventListener("wheel", wheel, { passive:false });

    // ----- public api (called from React + keyboard) -----
    const api = {
      focus(id) {
        if (id === "self") { api.exit(); return; }
        const n = byId.get(id); if (!n) return;
        focusId = id; orbit.spin = false;
        const nb = neighborsOf(id);
        // frame the cluster
        const c = n.pos.clone(); nb.forEach(k => c.add(k.pos)); c.multiplyScalar(1/(nb.length+1));
        lookTarget.copy(c);
        let spread = 6; nb.forEach(k => spread = Math.max(spread, k.pos.distanceTo(n.pos)));
        orbit.tDist = Math.min(34, spread * 1.7 + 9);
        setSelected(n);
        setNeighbors(nb.filter(x => x.id !== "self").sort((a,b)=> a.kind.localeCompare(b.kind)));
      },
      exit() {
        focusId = null; orbit.spin = true;
        lookTarget.set(0,0,0); orbit.tDist = 34;
        setSelected(null); setNeighbors([]);
      },
      cycle(dir) {
        if (focusId == null) return;
        const nb = neighborsOf(focusId).filter(x => x.id !== "self");
        if (!nb.length) return;
        // step among siblings sharing the same parent, else among neighbours
        api.focus(nb[(Math.max(0, nb.findIndex(x=>x.id===focusId)) + dir + nb.length) % nb.length].id);
      },
      home() { api.exit(); },
    };
    apiRef.current = api;

    // ----- resize -----
    function resize(){ const r=wrap.getBoundingClientRect(); renderer.setSize(r.width,r.height,false); camera.aspect=r.width/r.height; camera.updateProjectionMatrix(); }
    resize(); const ro=new ResizeObserver(resize); ro.observe(wrap);

    // ----- loop -----
    let raf; const t0=performance.now(); const tmp=new THREE.Vector3();
    function loop(){
      const t=(performance.now()-t0)/1000;
      const flt = filterRef.current;
      const active = focusId; const activeSet = active ? new Set([active, ...adj.get(active)]) : null;

      // node emphasis + glow + gentle self-spin of forms
      nodes.forEach(n => {
        const filteredOut = flt.size && !flt.has(n.kind);
        let target;
        if (filteredOut) target = 0.08;
        else if (active) target = activeSet.has(n.id) ? 1 : 0.12;
        else if (hoverId) target = (hoverId === n.id || adj.get(hoverId)?.has(n.id)) ? 1 : 0.4;
        else target = n.kind === "project" || n.kind === "self" ? 1 : 0.78;
        n._lit += (target - n._lit) * 0.12;

        n._glow.material.opacity = n._baseGlow * n._lit * (0.7 + 0.3*Math.sin(t*1.5 + n.pos.x));
        n._core.material.opacity = n._lit; n._core.material.transparent = true;
        if (n._ring) { n._ring.material.opacity = 0.5 * n._lit; n._ring.lookAt(camera.position); }
        if (n.kind !== "self" && n.kind !== "skill" && n.kind !== "project") n._core.rotation.y += 0.01;
        if (n.kind === "self") n._core.rotation.y += 0.004;
        const sc = 1 + (n._lit - 0.78) * 0.25 + (n.id===hoverId?0.12:0);
        n._core.scale.setScalar(Math.max(0.6, sc));
      });

      // edge emphasis
      edgeObjs.forEach(eo => {
        const filteredOut = flt.size && (!flt.has(byId.get(eo.aId).kind) || !flt.has(byId.get(eo.bId).kind));
        let tgt;
        if (filteredOut) tgt = 0.02;
        else if (active) tgt = (activeSet.has(eo.aId) && activeSet.has(eo.bId)) ? Math.max(0.5, eo.base) : 0.04;
        else if (hoverId) tgt = (eo.aId===hoverId || eo.bId===hoverId) ? Math.max(0.55, eo.base) : eo.base*0.6;
        else tgt = eo.base;
        eo.mat.opacity += (tgt - eo.mat.opacity) * 0.12;
      });

      // pulses
      for (let i=0;i<pulses.length;i++){
        const p=pulses[i]; p.t += p.speed*0.01; if (p.t>1) p.t=0;
        const v=p.eo.curve.getPoint(p.t);
        pPos[i*3]=v.x; pPos[i*3+1]=v.y; pPos[i*3+2]=v.z;
        // hide pulses not on the active cluster when focused
        const show = !active || (activeSet.has(p.eo.aId) && activeSet.has(p.eo.bId));
        if (!show) { pPos[i*3+1] = 9999; }
      }
      pulseGeom.attributes.position.needsUpdate = true;

      // camera
      if (orbit.spin && !orbit.drag) orbit.tAz += 0.0006;
      orbit.az += (orbit.tAz-orbit.az)*0.07;
      orbit.el += (orbit.tEl-orbit.el)*0.07;
      orbit.dist += (orbit.tDist-orbit.dist)*0.06;
      lookAt.lerp(lookTarget, 0.08);
      const az = orbit.az + orbit.cur.x*0.12, el = orbit.el + orbit.cur.y*0.06, d = orbit.dist;
      camera.position.set(
        lookAt.x + Math.cos(el)*Math.sin(az)*d,
        lookAt.y + Math.sin(el)*d,
        lookAt.z + Math.cos(el)*Math.cos(az)*d
      );
      camera.lookAt(lookAt);

      // labels (DOM overlay, depth-aware, decluttered)
      const r = renderer.domElement.getBoundingClientRect();
      nodes.forEach(n => {
        const el = labelEls.get(n.id); if (!el) return;
        n._g.getWorldPosition(tmp); const p = tmp.clone().project(camera);
        const filteredOut = flt.size && !flt.has(n.kind);
        // visibility policy
        let show;
        if (p.z > 1 || filteredOut) show = false;
        else if (active) show = activeSet.has(n.id);
        else if (hoverId) show = (hoverId === n.id || adj.get(hoverId)?.has(n.id));
        else show = (n.kind === "self" || n.kind === "project"); // at rest: spine only
        if (!show) { el.style.opacity = "0"; el.style.pointerEvents = "none"; return; }
        const sx=(p.x*0.5+0.5)*r.width, sy=(-p.y*0.5+0.5)*r.height;
        const depth = THREE.MathUtils.clamp((d - tmp.distanceTo(camera.position)) / d, -1, 1);
        el.style.transform = `translate(-50%,-50%) translate(${sx}px, ${sy - (n.weight*16+10)}px)`;
        el.style.opacity = String(0.55 + 0.45*n._lit);
        el.style.pointerEvents = "auto";
        el.classList.toggle("hot", (active && n.id===active) || n.id===hoverId);
      });

      renderer.render(scene, camera);
      raf=requestAnimationFrame(loop);
    }
    loop();

    // keyboard
    function onKey(e){
      if (e.key === "Escape") { api.exit(); }
      else if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); api.cycle(1); }
      else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); api.cycle(-1); }
    }
    const frameEl = wrap.closest(".graph-3d-frame");
    frameEl && frameEl.addEventListener("keydown", onKey);

    return () => {
      cancelAnimationFrame(raf); ro.disconnect();
      renderer.domElement.removeEventListener("pointerdown", down);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      renderer.domElement.removeEventListener("wheel", wheel);
      frameEl && frameEl.removeEventListener("keydown", onKey);
      labelEls.forEach(el => el.remove());
      renderer.dispose();
      try { wrap.removeChild(renderer.domElement); } catch (e) {}
    };
  }, []);

  function toggleFilter(kind) {
    setFilter(prev => {
      const next = new Set(prev);
      next.has(kind) ? next.delete(kind) : next.add(kind);
      return next;
    });
  }

  const KINDS = ["self", "project", "paper", "talk", "award", "skill"];

  return (
    <section id="graph" className="scene" data-screen-label="03 Graph">
      <div className="scene-head">
        <div className="eyebrow">The atlas</div>
        <h2>Everything I&rsquo;ve built, <em>connected</em>.</h2>
          <p className="lede">
            A living map of the work &mdash; projects, the proof attached to each (papers, talks, awards), and the stack they&rsquo;re built from. Hover to trace a connection. Click any node to step inside and walk the graph.
          </p>
      </div>

      <div className="graph-3d-frame" tabIndex={0}>
        <div className="graph-3d-canvas" ref={wrapRef} />
        <div className="graph-labels" ref={labelsRef} />

        {/* legend = filters */}
        <div className="graph-filters">
          {KINDS.map(k => (
            <button key={k}
              className={"gf" + (filter.size && !filter.has(k) ? " off" : "") + (filter.has(k) ? " on" : "")}
              onClick={() => toggleFilter(k)}>
              <span className="sw" style={{ background: ATLAS_HEX[k] }} />{k}
            </button>
          ))}
          {filter.size > 0 && <button className="gf clear" onClick={() => setFilter(new Set())}>clear</button>}
        </div>

        {/* controls */}
        <div className="graph-controls">
          <button title="zoom in" onClick={() => zoom(-1)}>+</button>
          <button title="zoom out" onClick={() => zoom(1)}>−</button>
          <button title="reset view" onClick={() => apiRef.current && apiRef.current.home()}>⌂</button>
        </div>

        <div className="graph-help mono">drag to orbit · click a node · <span className="kbd">←</span><span className="kbd">→</span> walk · <span className="kbd">esc</span> exit</div>

        {/* detail + connection rail */}
        {selected && (
          <div className="graph-detail panel elevated">
            <div className="panel-head">
              <span className="title" style={{ color: ATLAS_HEX[selected.kind] }}>{selected.kind}</span>
              <span className="meta" onClick={() => apiRef.current && apiRef.current.exit()} style={{ cursor:"pointer", color:"var(--ink-mute)" }}>close ✕</span>
            </div>
            <div className="panel-body">
              <div style={{ fontFamily:"var(--ff-display)", fontSize: 28, lineHeight: 1.12, marginBottom: 6 }}>{selected.label}</div>
              {selected.meta?.sub && <p style={{ color: ATLAS_HEX[selected.kind], fontFamily:"var(--ff-mono)", fontSize: 11, letterSpacing:".1em", textTransform:"uppercase", margin: "2px 0 8px" }}>{selected.meta.sub}</p>}
              {selected.meta?.blurb && <p style={{ color:"var(--ink-2)", fontSize: 13.5, margin: "6px 0" }}>{selected.meta.blurb}</p>}
              {selected.meta?.note && <p style={{ color:"var(--ink-2)", fontSize: 13, margin: "6px 0" }}>{selected.meta.note}</p>}
              {selected.meta?.year && <span className="tag" style={{ marginTop: 6 }}>{selected.meta.year}</span>}
              {selected.meta?.venue && <p style={{ fontFamily:"var(--ff-mono)", fontSize: 11, color:"var(--ink-mute)", margin: "10px 0 0" }}>{selected.meta.venue}</p>}
              {selected.meta?.org && selected.meta?.scope && <p style={{ fontFamily:"var(--ff-mono)", fontSize: 11, color:"var(--ink-mute)", margin: "10px 0 0" }}>{selected.meta.org} — {selected.meta.scope}</p>}
              {selected.meta?.items && (
                <div style={{ display:"flex", gap: 4, flexWrap:"wrap", marginTop: 10 }}>
                  {selected.meta.items.slice(0, 8).map((tg,i)=> <span key={i} className="tag">{tg}</span>)}
                </div>
              )}
              {selected.meta?.metrics && (
                <div style={{ display:"flex", gap: 18, flexWrap:"wrap", marginTop: 12 }}>
                  {selected.meta.metrics.map((m, i) => (
                    <div key={i} className="stat" style={{ minWidth: 0 }}>
                      <div className="v" style={{ fontSize: 26, color: ATLAS_HEX[selected.kind] }}>{m.v}</div>
                      <div className="l">{m.l}</div>
                    </div>
                  ))}
                </div>
              )}
              {selected.meta?.link && <a className="btn" style={{ marginTop: 14 }} href={selected.meta.link} target="_blank" rel="noreferrer">visit <span className="arr">→</span></a>}

              {neighbors.length > 0 && (
                <div className="graph-rail">
                  <div className="rail-label">Connected — click to travel</div>
                  <div className="rail-items">
                    {neighbors.map(nb => (
                      <button key={nb.id} className="rail-item" onClick={() => apiRef.current && apiRef.current.focus(nb.id)}>
                        <span className="sw" style={{ background: ATLAS_HEX[nb.kind] }} />
                        <span className="rl">{nb.label}</span>
                        <span className="rk">{nb.kind}</span>
                      </button>
                    ))}
                  </div>
                </div>
              )}
            </div>
          </div>
        )}
      </div>
    </section>
  );

  function zoom(dir) {
    // nudge zoom via a synthetic wheel on the orbit (handled through api if present)
    const ev = new WheelEvent("wheel", { deltaY: dir * 240, shiftKey: true });
    const cv = wrapRef.current && wrapRef.current.querySelector("canvas");
    cv && cv.dispatchEvent(ev);
  }
}

const ForceGraph = ForceGraph3D;
Object.assign(window, { ForceGraph, ForceGraph3D });
