// scene.jsx — the 3-layer projected mindmap renderer.
//
// Visual model:
//   • Three horizontal grid planes stacked vertically in 3D space.
//   • Each plane carries 8 cuboid nodes positioned on its local x/z plane.
//   • S-curve connections: cubic bezier through 3D space, sampled and projected.
//   • Free-rotation drag (yaw + pitch).
//   • Wheel scroll drives a continuous "focus" value (0..2) that picks which
//     layer is active. As focus moves, the whole stack slides left and the
//     active layer is cinematically rotated to top-down on the right.

const { useRef, useState, useEffect, useMemo, useCallback } = React;

// Map active focus → spread between 3D-tilted and top-down views, animated per layer
function layerFocusAmount(layerIdx, focus) {
  // focus is a continuous value 0..LAYERS.length-1
  const d = Math.abs(focus - layerIdx);
  return Math.max(0, 1 - d); // 1 when active, fades to 0 within ±1
}

function MindmapScene({ tweaks, stageId = "mm-stage", onFocusChange, scrollTarget, disableWheel = false }) {
  useLang();
  const router = (typeof useRouter === 'function') ? useRouter() : null;
  const { LAYERS, CROSS_EDGES } = window.MINDMAP_DATA;
  const W = 1600, H = 1000;

  // Single continuous scroll position. -1 = overview (all 3 layers shown
  // stacked, no highlight); 0 = layer 0 active; 1 = layer 1; 2 = layer 2.
  // Wheel moves scroll; focus and overviewBlend are derived from it so the
  // transition overview → L1 → L2 → L3 is one smooth motion.
  const SCROLL_MIN = -1;
  const SCROLL_MAX = LAYERS.length - 1;

  const [yaw, setYaw] = useState(-0.50);
  const [pitch, setPitch] = useState(20 * Math.PI / 180);
  const [scroll, setScroll] = useState(SCROLL_MIN); // start in overview
  const [hover, setHover] = useState(null);
  const [selected, setSelected] = useState(null); // {layerIdx, nodeId} or null
  const [popupMounted, setPopupMounted] = useState(false); // controls opacity transition
  const lastSelectedRef = useRef(null); // holds previous selection during fade-out

  // Manage mount lifecycle so the popup can fade out smoothly after deselection
  useEffect(() => {
    if (selected) {
      lastSelectedRef.current = selected;
      // Wait one frame so the element appears at opacity:0 before transitioning to 1
      const raf = requestAnimationFrame(() => setPopupMounted(true));
      return () => cancelAnimationFrame(raf);
    } else {
      setPopupMounted(false);
      const id = setTimeout(() => { lastSelectedRef.current = null; }, 320);
      return () => clearTimeout(id);
    }
  }, [selected]);

  // Close popup on Escape
  useEffect(() => {
    if (!selected) return;
    const onKey = (e) => { if (e.key === 'Escape') setSelected(null); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selected]);

  const targetRef = useRef({ yaw: -0.50, pitch: 20 * Math.PI / 180, scroll: SCROLL_MIN });
  const dragRef = useRef(null);
  const rafRef = useRef(null);

  // Ease toward target every frame
  useEffect(() => {
    const tick = () => {
      const t = targetRef.current;
      setYaw(prev => {
        const next = prev + (t.yaw - prev) * 0.18;
        return Math.abs(next - t.yaw) < 0.0001 ? t.yaw : next;
      });
      setPitch(prev => {
        const next = prev + (t.pitch - prev) * 0.18;
        return Math.abs(next - t.pitch) < 0.0001 ? t.pitch : next;
      });
      setScroll(prev => {
        const next = prev + (t.scroll - prev) * 0.10;
        return Math.abs(next - t.scroll) < 0.0005 ? t.scroll : next;
      });
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, []);

  // Drag to rotate
  const onPointerDown = (e) => {
    if (e.button !== 0) return;
    dragRef.current = { x: e.clientX, y: e.clientY,
                         yaw: targetRef.current.yaw,
                         pitch: targetRef.current.pitch };
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
  };
  const onPointerMove = (e) => {
    const d = dragRef.current; if (!d) return;
    const dx = e.clientX - d.x, dy = e.clientY - d.y;
    targetRef.current.yaw   = d.yaw   + dx * 0.005;
    targetRef.current.pitch = Math.max(-1.2, Math.min(1.2, d.pitch + dy * 0.005));
  };
  const onPointerUp = () => {
    dragRef.current = null;
    window.removeEventListener('pointermove', onPointerMove);
    window.removeEventListener('pointerup', onPointerUp);
  };

  // Wheel → snap to discrete positions: -1 (overview), 0, 1, 2.
  // We accumulate a deadzone so a single wheel notch advances one stop.
  const wheelAccum = useRef(0);
  const wheelLockUntil = useRef(0);
  const onWheel = (e) => {
    e.preventDefault();
    const t = targetRef.current;
    const now = performance.now();
    if (now < wheelLockUntil.current) return;
    wheelAccum.current += e.deltaY;
    const THRESH = 90;
    if (wheelAccum.current > THRESH) {
      t.scroll = Math.min(SCROLL_MAX, Math.round(t.scroll + 1));
      wheelAccum.current = 0;
      wheelLockUntil.current = now + 650;
    } else if (wheelAccum.current < -THRESH) {
      t.scroll = Math.max(SCROLL_MIN, Math.round(t.scroll - 1));
      wheelAccum.current = 0;
      wheelLockUntil.current = now + 650;
    }
  };

  useEffect(() => {
    if (disableWheel) return;
    const el = document.getElementById(stageId);
    if (!el) return;
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [stageId, disableWheel]);

  // Controlled mode: external scrollTarget drives the easing target.
  useEffect(() => {
    if (scrollTarget !== undefined && scrollTarget !== null) {
      const v = Math.max(SCROLL_MIN, Math.min(SCROLL_MAX, scrollTarget));
      targetRef.current.scroll = v;
    }
  }, [scrollTarget, SCROLL_MIN, SCROLL_MAX]);

  // Notify parent when scroll/focus changes (used to fade external content).
  useEffect(() => {
    if (onFocusChange) onFocusChange({ scroll, focus: Math.max(0, scroll), overviewBlend: Math.max(0, Math.min(1, -scroll)) });
  }, [scroll, onFocusChange]);

  // Derive focus + overviewBlend from scroll.
  // scroll = -1 → overview (blend=1, focus=0)
  // scroll =  0 → layer 0 fully focused (blend=0, focus=0)
  // scroll in (-1, 0) → smoothly transitions overview→L1
  const overviewBlend = Math.max(0, Math.min(1, -scroll));
  const focus = Math.max(0, scroll);

  // In overview, hover is disabled.
  const effectiveHover = overviewBlend > 0.5 ? null : hover;

  // ── Geometry ──────────────────────────────────────────────────────────────

  // Layout in 3D world space, with layers stacked on Y axis.
  // Each layer is a square plane in x/z spanning [-1,1] × [-1,1].
  const PLANE_HALF = 1.0;
  const layerSpacing = tweaks.layerSpacing;     // distance between layers along Y
  const sceneScale   = tweaks.sceneScale;       // px per world unit
  const stackShiftX  = -focus * tweaks.focusShift * (1 - overviewBlend); // shift entire stack left as focus increases (off in overview)
  const baseCenterX  = W * 0.42 + overviewBlend * (W * 0.08);  // recenter when in overview
  // Pull the stack upward in overview so the strata occupy more of the visible
  // canvas (less dead space at the top); ease back to 0.55 once a layer focuses
  const baseCenterY  = H * (0.55 - overviewBlend * 0.10);

  // Overview keeps the base pitch (20° default) — no extra bias.
  const overviewPitchBias = 0;
  const effPitch = pitch + overviewBlend * overviewPitchBias;

  // World position of a point on a given layer
  const layerY = (idx) => (LAYERS.length - 1) / 2 * layerSpacing - idx * layerSpacing;

  const rotMat = M3.rot(yaw, effPitch);

  const layerPoses = LAYERS.map((_, i) => {
    // In overview, suppress the per-layer focus split entirely.
    const f = layerFocusAmount(i, focus) * (1 - overviewBlend);
    // Topdown target offset (right panel center) — pushed further right so the
    // focused 2D layer separates clearly from the still-stacked 3-strata view
    const targetX = W * 0.86;
    const targetY = H * 0.50;
    // Local rotation override: blend pitch from `pitch` to π/2 and yaw to 0
    const targetPitch = Math.PI / 2;
    const targetYaw   = 0;
    const blendPitch = effPitch + (targetPitch - effPitch) * f;
    const blendYaw   = yaw + (targetYaw - yaw) * f;
    const localRot = M3.rot(blendYaw, blendPitch);
    return {
      idx: i,
      f,
      rot: localRot,
      // Center offset interpolation
      cx: baseCenterX + stackShiftX + (targetX - (baseCenterX + stackShiftX)) * f,
      cy: baseCenterY + (targetY - baseCenterY) * f,
      // Scale up in overview (bigger strata on the landing screen) and again
      // slightly when a layer fully focuses
      scale: sceneScale * (1 + 0.22 * overviewBlend) * (1 + 0.08 * f),
      // Push focused layer "outward" from the others vertically (cinematic split)
      yLift: -f * 0.0,
    };
  });

  // Project a point [x, layerY, z] using layer pose
  const projectInLayer = (layerIdx, [x, z]) => {
    const pose = layerPoses[layerIdx];
    const yWorld = layerY(layerIdx) + pose.yLift;
    const p3 = M3.apply(pose.rot, [x, yWorld, z]);
    return M3.project(p3, pose.scale, pose.cx, pose.cy);
  };

  // ── Renderable elements (with depth for sorting) ──────────────────────────

  // Grid plane render: lines at intervals across [-1,1]^2 plus a border.
  // Density varies per layer for visual contrast — sparse → medium → dense.
  function buildGridLines(layerIdx) {
    const layer = LAYERS[layerIdx];
    const STEPS = layer.gridSteps || tweaks.gridSteps;
    const lines = [];
    for (let i = 0; i <= STEPS; i++) {
      const t = -1 + (2 * i) / STEPS;
      // x-line
      const a = projectInLayer(layerIdx, [-1, t]);
      const b = projectInLayer(layerIdx, [ 1, t]);
      // z-line
      const c = projectInLayer(layerIdx, [t, -1]);
      const d = projectInLayer(layerIdx, [t,  1]);
      const isEdge = (i === 0 || i === STEPS);
      lines.push({ a, b, isEdge });
      lines.push({ a: c, b: d, isEdge });
    }
    return lines;
  }

  // Cuboid node: 8 verts, draw 6 faces with simple lighting + outline edges
  function buildNode(layerIdx, node) {
    const pose = layerPoses[layerIdx];
    const [nx, nz] = node.pos;
    const yWorld = layerY(layerIdx) + pose.yLift;
    const halfH = 0.06;          // node thickness in Y
    const halfX = 0.10, halfZ = 0.10;
    const center = [nx, yWorld + halfH, nz];
    const verts = M3.cuboidVerts(center, [halfX, halfH, halfZ]);
    const projected = verts.map(v => {
      const p3 = M3.apply(pose.rot, v);
      return M3.project(p3, pose.scale, pose.cx, pose.cy);
    });
    const rotated = verts.map(v => M3.apply(pose.rot, v));
    const faces = M3.cuboidFaces().map((f, fi) => {
      const r = M3.apply(pose.rot, f.normal);
      const visible = r[2] > -0.05;
      const avgZ = f.verts.reduce((s, vi) => s + projected[vi].z, 0) / 4;
      const upness = M3.apply(pose.rot, f.normal)[1];
      const isTop = fi === 3; // +Y face
      return { ...f, visible, avgZ, upness, isTop, faceIdx: fi };
    });
    return { node, layerIdx, projected, faces, center3d: rotated[0] };
  }

  // S-curve cubic bezier in 2D screen space between two projected endpoints.
  // For inter-layer connections we need the curve to follow the actual
  // projected positions of the two endpoint nodes — which already account
  // for rotation, scroll-driven focus, and the cinematic split. Building the
  // bezier in screen space (rather than world space + projection) keeps it
  // synced with whatever pose changes happen.
  function buildCurve2D(pA, pB, sameLayer) {
    const ax = pA.x, ay = pA.y, bx = pB.x, by = pB.y;
    const dx = bx - ax, dy = by - ay;
    const len = Math.hypot(dx, dy) || 0.001;
    const px = -dy / len, py = dx / len;
    let c1x, c1y, c2x, c2y;
    if (sameLayer) {
      const off = Math.min(48, len * 0.22);
      c1x = ax + dx*0.30 + px*off; c1y = ay + dy*0.30 + py*off - 6;
      c2x = bx - dx*0.30 - px*off; c2y = by - dy*0.30 - py*off - 6;
    } else {
      const off = Math.min(80, len * 0.30);
      c1x = ax + dx*0.20 + px*off; c1y = ay + dy*0.20 + py*off;
      c2x = bx - dx*0.20 - px*off; c2y = by - dy*0.20 - py*off;
    }
    const samples = 28;
    const pts = [];
    let avgZ = (pA.z + pB.z) / 2;
    for (let i = 0; i <= samples; i++) {
      const t = i / samples, u = 1 - t;
      const b0 = u*u*u, b1 = 3*u*u*t, b2 = 3*u*t*t, b3 = t*t*t;
      pts.push({
        x: b0*ax + b1*c1x + b2*c2x + b3*bx,
        y: b0*ay + b1*c1y + b2*c2y + b3*by,
      });
    }
    return { pts, avgDepth: avgZ };
  }

  // S-curve cubic bezier between two world points (kept for same-layer 3D arc)
  function buildCurve(aWorld, bWorld, opts = {}) {
    const { sameLayer = true, samples = 36, layerIdxA, layerIdxB } = opts;
    const [ax, ay, az] = aWorld, [bx, by, bz] = bWorld;
    // Two control points create the S-curve.
    // For same-layer: both controls slightly above the layer (or "lifted") and
    // pulled toward the midpoint with offset to make an S.
    // For inter-layer: vertical lift between layers, offset left/right alternately.
    const mx = (ax+bx)/2, my = (ay+by)/2, mz = (az+bz)/2;
    const dx = bx - ax, dz = bz - az;
    const len = Math.hypot(dx, dz) || 0.001;
    // Tangent perpendicular in xz
    const px = -dz / len, pz = dx / len;
    let c1, c2;
    if (sameLayer) {
      const lift = 0.02;
      const off = 0.18 * len;
      c1 = [ax + dx*0.30 + px*off, ay + lift, az + dz*0.30 + pz*off];
      c2 = [bx - dx*0.30 - px*off, by + lift, bz - dz*0.30 - pz*off];
    } else {
      const off = 0.32;
      c1 = [ax + dx*0.20 + px*off, ay + (by-ay)*0.20, az + dz*0.20 + pz*off];
      c2 = [bx - dx*0.20 - px*off, ay + (by-ay)*0.80, bz - dz*0.20 - pz*off];
    }
    const pts = [];
    let avgDepth = 0;
    for (let i = 0; i <= samples; i++) {
      const t = i / samples;
      const w = M3.bezier(t, [ax,ay,az], c1, c2, [bx,by,bz]);
      // Pick pose: blend? For simplicity, project each sample using the
      // closer layer's pose weighted by t.
      let pose;
      if (sameLayer) pose = layerPoses[layerIdxA];
      else {
        // Linear blend of poses: choose pose by t but apply same shift+scale
        // To keep things simple, project with layerIdxA pose for t<0.5 else B.
        // Better: blend cx/cy/scale and rot.
        const poseA = layerPoses[layerIdxA], poseB = layerPoses[layerIdxB];
        pose = {
          rot: poseA.rot, // rotation difference between layers tiny except when one is focused
          cx: M3.lerp(poseA.cx, poseB.cx, t),
          cy: M3.lerp(poseA.cy, poseB.cy, t),
          scale: M3.lerp(poseA.scale, poseB.scale, t),
        };
        // Use the pose that is closer for rotation — this is okay because
        // mostly only one layer is focused at a time.
        if (poseA.f > poseB.f) pose.rot = poseA.rot;
        else                    pose.rot = poseB.rot;
      }
      const r3 = M3.apply(pose.rot, w);
      const proj = M3.project(r3, pose.scale, pose.cx, pose.cy);
      pts.push(proj);
      avgDepth += proj.z;
    }
    avgDepth /= pts.length + 1;
    return { pts, avgDepth };
  }

  // World-space node center (above the plane, at top of the cuboid)
  const nodeWorldCenter = (layerIdx, node) => {
    const [nx, nz] = node.pos;
    const yWorld = layerY(layerIdx);
    return [nx, yWorld + 0.12, nz];
  };

  // Build all elements
  const elements = []; // { kind, depth, render }

  // Grid lines, planes
  LAYERS.forEach((layer, li) => {
    const lines = buildGridLines(li);
    // Add a quad fill behind
    const corners = [
      projectInLayer(li, [-1,-1]),
      projectInLayer(li, [ 1,-1]),
      projectInLayer(li, [ 1, 1]),
      projectInLayer(li, [-1, 1]),
    ];
    const avgZ = corners.reduce((s,c) => s+c.z, 0)/4;
    const f = layerPoses[li].f;
    const layerInk = M3.lerp(layer.inkOpacity, 0.85, overviewBlend);
    const inkOpacity = layerInk * (0.25 + 0.75 * Math.max(f, 0.35));
    const dim = (focus - li); // signed
    // Other-layer fade. In overview, all layers shown equally (no fade).
    const fade = M3.lerp(Math.max(0.18, 1 - Math.abs(dim) * 0.55), 1.0, overviewBlend);
    const finalOp = inkOpacity * fade;

    elements.push({
      kind: 'plane-fill',
      depth: avgZ - 1000,
      render: () => (
        <polygon key={`pf-${li}`}
          points={corners.map(c => `${c.x},${c.y}`).join(' ')}
          fill={tweaks.paperFill}
          opacity={0.55 * fade}
        />
      ),
    });

    lines.forEach((ln, i) => {
      elements.push({
        kind: 'grid',
        depth: (ln.a.z + ln.b.z) / 2 - 100,
        render: () => (
          <line key={`g-${li}-${i}`}
            x1={ln.a.x} y1={ln.a.y} x2={ln.b.x} y2={ln.b.y}
            stroke={tweaks.inkColor}
            strokeOpacity={(ln.isEdge ? 0.9 : 0.4 * (layer.gridContrast ?? 1)) * finalOp}
            strokeWidth={ln.isEdge ? 1.6 : 1.1}
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
        ),
      });
    });

    // Layer label (subtle)
    const labelPos = projectInLayer(li, [-1.05, 1.05]);
    const labelOp = (0.35 + 0.65 * f) * fade;
    elements.push({
      kind: 'layer-label',
      depth: avgZ - 50,
      render: () => (
        <g key={`ll-${li}`} style={{pointerEvents:'none'}}>
          <text x={labelPos.x} y={labelPos.y - 8}
                fill={tweaks.inkColor}
                fillOpacity={labelOp}
                fontFamily="'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace"
                fontSize={11} letterSpacing="0.18em">
            {layer.id} · {layer.title}
          </text>
          <text x={labelPos.x} y={labelPos.y + 4}
                fill={tweaks.inkColor}
                fillOpacity={labelOp * 0.55}
                fontFamily="'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace"
                fontSize={9} letterSpacing="0.14em">
            {layer.subtitle.toUpperCase()}
          </text>
        </g>
      ),
    });

    // Coordinate ticks at corners
    const tickAt = (x,z) => {
      const p = projectInLayer(li, [x,z]);
      return p;
    };
    [[-1,-1],[1,-1],[1,1],[-1,1]].forEach(([cx,cz], ci) => {
      const p = tickAt(cx, cz);
      elements.push({
        kind: 'tick',
        depth: p.z - 40,
        render: () => (
          <text key={`tk-${li}-${ci}`} x={p.x + (cx>0?6:-6)} y={p.y + (cz>0?-6:12)}
                fill={tweaks.inkColor}
                fillOpacity={0.35 * fade}
                fontFamily="'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace"
                fontSize={8}
                textAnchor={cx>0?'start':'end'}>
            {`${cx>0?'+':''}${cx},${cz>0?'+':''}${cz}`}
          </text>
        ),
      });
    });
  });

  // Build all node objects keyed for lookup
  const allNodes = {};
  LAYERS.forEach((layer, li) => {
    layer.nodes.forEach(n => {
      const key = `${li}:${n.id}`;
      allNodes[key] = buildNode(li, n);
    });
  });

  // Determine connected nodes for hover highlight
  const connectedSet = new Set();
  if (effectiveHover) {
    const hk = `${effectiveHover.layerIdx}:${effectiveHover.nodeId}`;
    connectedSet.add(hk);
    LAYERS[effectiveHover.layerIdx].edges.forEach(([a,b]) => {
      if (a === effectiveHover.nodeId) connectedSet.add(`${effectiveHover.layerIdx}:${b}`);
      if (b === effectiveHover.nodeId) connectedSet.add(`${effectiveHover.layerIdx}:${a}`);
    });
    CROSS_EDGES.forEach(([la, na, lb, nb]) => {
      if (la === effectiveHover.layerIdx && na === effectiveHover.nodeId) connectedSet.add(`${lb}:${nb}`);
      if (lb === effectiveHover.layerIdx && nb === effectiveHover.nodeId) connectedSet.add(`${la}:${na}`);
    });
  }
  const isConnected = (li, nid) => connectedSet.has(`${li}:${nid}`);

  // Same-layer edges
  LAYERS.forEach((layer, li) => {
    layer.edges.forEach(([aId, bId], ei) => {
      const a = layer.nodes.find(n => n.id === aId);
      const b = layer.nodes.find(n => n.id === bId);
      if (!a || !b) return;
      const aw = nodeWorldCenter(li, a);
      const bw = nodeWorldCenter(li, b);
      const curve = buildCurve(aw, bw, { sameLayer: true, layerIdxA: li, layerIdxB: li });
      const f = layerPoses[li].f;
      const fade = M3.lerp(Math.max(0.18, 1 - Math.abs(focus - li) * 0.55), 1.0, overviewBlend);
      const layerInk = M3.lerp(layer.inkOpacity, 0.85, overviewBlend);
      const baseOp = layerInk * (0.45 + 0.55 * Math.max(f, 0.4)) * fade;
      const highlighted = effectiveHover && (
        (effectiveHover.layerIdx === li && (effectiveHover.nodeId === aId || effectiveHover.nodeId === bId))
      );
      const dimmed = effectiveHover && !highlighted;
      const op = highlighted ? 1 : (dimmed ? baseOp * 0.18 : baseOp);
      const sw = highlighted ? 2.0 : 1.3;
      elements.push({
        kind: 'edge',
        // Bias edges behind same-layer node boxes so boxes always occlude
        // the line segments that visually pass over them.
        depth: curve.avgDepth - 5,
        render: () => (
          <polyline key={`e-${li}-${ei}`}
            points={curve.pts.map(p => `${p.x},${p.y}`).join(' ')}
            fill="none"
            stroke={tweaks.inkColor}
            strokeOpacity={op}
            strokeWidth={sw}
            strokeLinecap="round"
            strokeLinejoin="round"
            vectorEffect="non-scaling-stroke"
          />
        ),
      });
    });
  });

  // Project a node's center on a given layer through that layer's CURRENT
  // pose — auto-syncs with rotation, focus split, and scroll position.
  const projectNodeCenter = (layerIdx, node) => {
    const [nx, nz] = node.pos;
    const pose = layerPoses[layerIdx];
    const yWorld = layerY(layerIdx) + pose.yLift;
    const halfH = 0.06;
    const c3 = M3.apply(pose.rot, [nx, yWorld + halfH, nz]);
    return M3.project(c3, pose.scale, pose.cx, pose.cy);
  };

  // Cross-layer edges — built in screen space so they follow whichever
  // pose each endpoint's layer happens to be in.
  CROSS_EDGES.forEach(([la, na, lb, nb], ei) => {
    const A = LAYERS[la].nodes.find(n => n.id === na);
    const B = LAYERS[lb].nodes.find(n => n.id === nb);
    if (!A || !B) return;
    const pA = projectNodeCenter(la, A);
    const pB = projectNodeCenter(lb, B);
    const curve = buildCurve2D(pA, pB, false);
    // Fade strongly when either layer is far from focus, OR when one is fully focused (separated)
    const fa = layerPoses[la].f, fb = layerPoses[lb].f;
    const dimSplit = Math.max(fa, fb); // when active layer is split out, dim cross edges
    const fadeA = M3.lerp(Math.max(0.2, 1 - Math.abs(focus - la) * 0.5), 1.0, overviewBlend);
    const fadeB = M3.lerp(Math.max(0.2, 1 - Math.abs(focus - lb) * 0.5), 1.0, overviewBlend);
    const baseOp = 0.55 * Math.min(fadeA, fadeB) * (1 - dimSplit * 0.85);
    const highlighted = effectiveHover && (
      (effectiveHover.layerIdx === la && effectiveHover.nodeId === na) ||
      (effectiveHover.layerIdx === lb && effectiveHover.nodeId === nb)
    );
    const dimmed = effectiveHover && !highlighted;
    const op = highlighted ? 0.95 : (dimmed ? baseOp * 0.12 : baseOp);
    elements.push({
      kind: 'cross-edge',
      depth: curve.avgDepth - 5,
      render: () => (
        <polyline key={`ce-${la}-${ei}`}
          points={curve.pts.map(p => `${p.x},${p.y}`).join(' ')}
          fill="none"
          stroke={tweaks.inkColor}
          strokeOpacity={op}
          strokeWidth={highlighted ? 1.8 : 1.1}
          strokeDasharray={highlighted ? '0' : '4 4'}
          strokeLinecap="round"
          vectorEffect="non-scaling-stroke"
        />
      ),
    });
  });

  // Nodes (cuboids + shadow + label on hover)
  LAYERS.forEach((layer, li) => {
    layer.nodes.forEach((n, ni) => {
      const N = allNodes[`${li}:${n.id}`];
      const f = layerPoses[li].f;
      const fade = M3.lerp(Math.max(0.25, 1 - Math.abs(focus - li) * 0.5), 1.0, overviewBlend);
      const baseOp = (0.7 + 0.3 * f) * fade;
      const isHover = effectiveHover && effectiveHover.layerIdx === li && effectiveHover.nodeId === n.id;
      const isConn = isConnected(li, n.id);
      const dimmed = effectiveHover && !isHover && !isConn;
      const opMul = dimmed ? 0.25 : 1;

      // Shadow (projected on plane)
      const shadowVerts = N.projected.filter((_, i) => (i % 2 === 0)); // bottom 4 (y=-1)
      const shadowPath = shadowVerts.map(p => `${p.x},${p.y}`).join(' ');
      const planeProj = projectInLayer(li, [n.pos[0], n.pos[1]]);
      const shadowAvgZ = shadowVerts.reduce((s,p)=>s+p.z,0)/4;
      elements.push({
        kind: 'shadow',
        depth: shadowAvgZ - 30,
        render: () => (
          <ellipse key={`sh-${li}-${ni}`}
            cx={planeProj.x} cy={planeProj.y + 6}
            rx={16} ry={5}
            fill={tweaks.inkColor}
            opacity={0.18 * fade * opMul}
          />
        ),
      });

      // Faces
      const faces = N.faces
        .filter(f => f.visible)
        .sort((a, b) => a.avgZ - b.avgZ);
      // Single hull polygon — no internal seams. Fill once with neutral color
      // and overlay the layer pattern across the whole silhouette.
      if (faces.length > 0) {
        const visVerts = new Set();
        faces.forEach(f => f.verts.forEach(vi => visVerts.add(vi)));
        const hullPts = convexHull2D([...visVerts].map(vi => N.projected[vi]));
        const hullStr = hullPts.map(p => `${p.x},${p.y}`).join(' ');
        const avgZ = faces.reduce((s,f)=>s+f.avgZ,0) / faces.length;
        const patternId = `pat-${layer.pattern}-${li}`;
        const fillCol = isHover ? `oklch(0.93 0.003 70)` : `oklch(0.88 0.002 70)`;
        elements.push({
          kind: 'box',
          depth: avgZ,
          render: () => (
            <g key={`box-${li}-${ni}`}
               onMouseEnter={() => window.__hasHover && setHover({ layerIdx: li, nodeId: n.id })}
               onMouseLeave={() => window.__hasHover && setHover(null)}
               onClick={(e) => {
                 e.stopPropagation();
                 setSelected(prev =>
                   prev && prev.layerIdx === li && prev.nodeId === n.id
                     ? null
                     : { layerIdx: li, nodeId: n.id }
                 );
               }}
               style={{ cursor: 'pointer' }}>
              <polygon points={hullStr}
                fill={fillCol}
                fillOpacity={baseOp * opMul}
                stroke="none"
              />
              <polygon points={hullStr}
                fill={`url(#${patternId})`}
                fillOpacity={0.85 * opMul * fade}
                stroke="none"
                pointerEvents="none"
              />
              <polygon points={hullStr}
                fill="none"
                stroke={tweaks.inkColor}
                strokeOpacity={(isHover ? 1 : 0.9) * baseOp * opMul}
                strokeWidth={isHover ? 2.4 : 1.8}
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
                pointerEvents="none"
              />
            </g>
          ),
        });
      }

    });
  });

  // Sort by depth (back to front)
  elements.sort((a, b) => a.depth - b.depth);

  // ── UI overlays ──────────────────────────────────────────────────────────

  const activeIdx = Math.round(focus);
  const activeLayer = LAYERS[activeIdx];
  const inOverview = overviewBlend > 0.5;
  // Map scroll [-1, N-1] → [0, 1] for the progress indicator
  const scrollPct = (scroll - SCROLL_MIN) / (SCROLL_MAX - SCROLL_MIN);

  // Compute popup placement (in viewBox coords). We render for either the
  // current selection OR the last-selected (during fade-out) so the box can
  // animate opacity from 1→0 before unmounting.
  let popup = null;
  const popupSource = selected || lastSelectedRef.current;
  if (popupSource) {
    const N = allNodes[`${popupSource.layerIdx}:${popupSource.nodeId}`];
    const layer = LAYERS[popupSource.layerIdx];
    const nodeData = layer.nodes.find(n => n.id === popupSource.nodeId);
    if (N && nodeData) {
      const xs = N.projected.map(p => p.x);
      const ys = N.projected.map(p => p.y);
      const bx0 = Math.min(...xs), bx1 = Math.max(...xs);
      const by0 = Math.min(...ys), by1 = Math.max(...ys);
      const popupW = 360, popupH = nodeData.route ? 260 : 200, gap = 24;
      let px;
      if (bx1 + gap + popupW <= W) px = bx1 + gap;
      else if (bx0 - gap - popupW >= 0) px = bx0 - gap - popupW;
      else px = Math.max(0, Math.min(W - popupW, bx0));
      let py = by0;
      if (py + popupH > H) py = Math.max(0, H - popupH);
      popup = {
        x: px, y: py, w: popupW, h: popupH,
        layer, node: nodeData,
        anchorRight: bx1 < px, // popup is to the right of the box
      };
    }
  }

  return (
    <div id={stageId} onPointerDown={onPointerDown}
         onClick={(e) => {
           // Click on bare stage closes any open popup. Box clicks stopPropagation.
           if (selected) setSelected(null);
         }}
         style={{ position:'absolute', inset:0, cursor: dragRef.current ? 'grabbing' : 'grab',
                  background: tweaks.bgColor }}>
      {/* Paper grain */}
      <div style={{ position:'absolute', inset:0, pointerEvents:'none',
                    backgroundImage: 'radial-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
                    backgroundSize: '3px 3px', opacity: 0.6 }}/>

      <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="100%"
           preserveAspectRatio="xMidYMid meet"
           style={{ position:'absolute', inset:0 }}>
        <defs>
          {LAYERS.map((layer, li) => {
            const id = `pat-${layer.pattern}-${li}`;
            const ink = tweaks.inkColor;
            if (layer.pattern === 'dots') {
              return (
                <pattern key={id} id={id} width="6" height="6" patternUnits="userSpaceOnUse">
                  <circle cx="3" cy="3" r="0.7" fill={ink} fillOpacity="0.55" />
                </pattern>
              );
            }
            if (layer.pattern === 'diag') {
              return (
                <pattern key={id} id={id} width="5" height="5" patternUnits="userSpaceOnUse"
                         patternTransform="rotate(45)">
                  <line x1="0" y1="0" x2="0" y2="5" stroke={ink} strokeOpacity="0.5" strokeWidth="0.6" />
                </pattern>
              );
            }
            // cross-hatch
            return (
              <pattern key={id} id={id} width="6" height="6" patternUnits="userSpaceOnUse">
                <path d="M0,0 L6,6 M0,6 L6,0" stroke={ink} strokeOpacity="0.45" strokeWidth="0.5" />
              </pattern>
            );
          })}
        </defs>
        {elements.map((el) => el.render())}
        {/* Selected-node popup — rendered inside SVG so it tracks viewBox coords */}
        {popup && (
          <foreignObject x={popup.x} y={popup.y} width={popup.w} height={popup.h}
                         style={{
                           overflow: 'visible',
                           pointerEvents: popupMounted ? 'auto' : 'none',
                         }}>
            <div xmlns="http://www.w3.org/1999/xhtml"
                 onClick={(e) => e.stopPropagation()}
                 onPointerDown={(e) => e.stopPropagation()}
                 style={{
                   background: 'var(--bg)',
                   border: '1px solid var(--fg)',
                   padding: '20px 22px',
                   boxShadow: popupMounted
                     ? '0 22px 44px -16px rgba(0,0,0,0.28)'
                     : '0 8px 18px -12px rgba(0,0,0,0.10)',
                   fontFamily: '"Pretendard Variable", Pretendard, "Apple SD Gothic Neo", system-ui, sans-serif',
                   color: 'var(--fg)',
                   opacity: popupMounted ? 1 : 0,
                   transform: popupMounted
                     ? 'translateX(0) scale(1)'
                     : `translateX(${popup.anchorRight ? -10 : 10}px) scale(0.97)`,
                   transformOrigin: popup.anchorRight ? 'left center' : 'right center',
                   transition: 'opacity 280ms cubic-bezier(0.22, 0.61, 0.36, 1), transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), box-shadow 280ms cubic-bezier(0.22, 0.61, 0.36, 1)',
                   willChange: 'opacity, transform',
                 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12, gap: 12 }}>
                <div style={{ fontSize: 11, letterSpacing: '0.18em', color: 'var(--fg-muted)', textTransform: 'uppercase' }}>
                  {popup.layer.id} · {popup.layer.title}
                </div>
                <button type="button"
                        onClick={(e) => { e.stopPropagation(); setSelected(null); }}
                        data-cursor="link" data-cursor-label="Close"
                        style={{
                          border: '1px solid var(--line)',
                          background: 'var(--bg)',
                          color: 'var(--fg)',
                          width: 24, height: 24, lineHeight: 1, fontSize: 14,
                          cursor: 'pointer', padding: 0,
                        }}>×</button>
              </div>
              <div style={{
                fontSize: 26, fontWeight: 800, letterSpacing: '-0.028em',
                lineHeight: 1.15, marginBottom: 12,
              }}>
                {popup.node.label}
              </div>
              <div style={{
                fontSize: 14, lineHeight: 1.55,
                color: 'var(--fg-muted)',
                letterSpacing: '-0.01em',
                marginBottom: popup.node.route && router ? 16 : 0,
              }}>
                {popup.node.desc || ''}
              </div>
              {popup.node.route && router && (
                <button type="button"
                        onClick={(e) => {
                          e.stopPropagation();
                          setSelected(null);
                          router.go(popup.node.route);
                        }}
                        data-cursor="link" data-cursor-label="Open"
                        style={{
                          display: 'inline-flex',
                          alignItems: 'center',
                          gap: 8,
                          padding: '10px 16px',
                          border: '1px solid var(--fg)',
                          background: 'var(--fg)',
                          color: 'var(--bg)',
                          fontSize: 12,
                          letterSpacing: '0.18em',
                          textTransform: 'uppercase',
                          cursor: 'pointer',
                          fontFamily: '"Pretendard Variable", Pretendard, "Apple SD Gothic Neo", system-ui, sans-serif',
                          fontWeight: 600,
                        }}>
                  Open {popup.node.label} →
                </button>
              )}
            </div>
          </foreignObject>
        )}
      </svg>

      {/* Header — only fades in once the user scrolls past overview */}
      <div style={{ position:'absolute', top:24, left:32, fontFamily:"'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace",
                    color: tweaks.inkColor, pointerEvents:'none',
                    opacity: 1 - overviewBlend,
                    transform: `translateY(${overviewBlend * -8}px)`,
                    transition: 'opacity 280ms var(--easing-default), transform 280ms var(--easing-default)' }}>
        <div style={{ fontSize:10, letterSpacing:'0.32em', opacity:0.55 }}>{t("FIG. 01 — KNOWLEDGE GRAPH")}</div>
        <div style={{ fontSize:22, fontWeight: window.__lang === 'kr' ? 600 : 500, marginTop:6, letterSpacing:'-0.01em',
                      fontFamily: window.__lang === 'kr'
                        ? '"Pretendard Variable", Pretendard, "Apple SD Gothic Neo", system-ui, sans-serif'
                        : "'EB Garamond', 'Times New Roman', serif",
                      fontStyle: window.__lang === 'kr' ? 'normal' : 'italic' }}>
          {t("Three Strata of Inquiry")}
        </div>
        <div style={{ fontSize:10, letterSpacing:'0.18em', opacity:0.5, marginTop:6 }}>
          {t("PHILOSOPHY · ART · SCIENCE  ·  N=24  ·  E=42")}
        </div>
      </div>

      {/* Scroll progress indicator (right vertical) */}
      <div style={{ position:'absolute', right:24, top:'50%', transform:'translateY(-50%)',
                    width:3, height:280, background:'rgba(10,10,10,0.18)' }}>
        <div style={{
          position:'absolute', left:-3.5, width:10, height:10, borderRadius:'50%',
          background: tweaks.inkColor,
          top: `${scrollPct * 100}%`,
          transform:'translateY(-50%)',
          transition:'top 0.05s linear',
        }}/>
        {/* Overview tick */}
        <div style={{
          position:'absolute', right:-14, top:'0%', transform:'translateY(-50%)',
          fontFamily:"'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace",
          fontSize:9, letterSpacing:'0.18em',
          color: tweaks.inkColor,
          opacity: inOverview ? 0.9 : 0.35,
        }}>··</div>
        {LAYERS.map((l, i) => {
          const pct = ((i - SCROLL_MIN) / (SCROLL_MAX - SCROLL_MIN)) * 100;
          return (
            <div key={l.id} style={{
              position:'absolute', right:-14,
              top:`${pct}%`,
              transform:'translateY(-50%)',
              fontFamily:"'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace",
              fontSize:9, letterSpacing:'0.18em',
              color: tweaks.inkColor,
              opacity: !inOverview && i === activeIdx ? 0.9 : 0.35,
            }}>
              {l.id}
            </div>
          );
        })}
      </div>

      {/* Bottom legend / instructions — right-aligned */}
      <div style={{ position:'absolute', bottom:24, left:32, right:32,
                    display:'flex', justifyContent:'flex-end', gap:32,
                    fontFamily:"'JetBrains Mono', ui-monospace, 'Pretendard Variable', Pretendard, monospace",
                    fontSize:10, letterSpacing:'0.18em', opacity:0.55,
                    color: tweaks.inkColor, pointerEvents:'none', textAlign:'right' }}>
        <div>DRAG · ROTATE  ◇  WHEEL · TRAVERSE STRATA  ◇  HOVER · TRACE</div>
        <div>YAW {(yaw*180/Math.PI).toFixed(0).padStart(3,' ')}°  ·  PITCH {(effPitch*180/Math.PI).toFixed(0).padStart(3,' ')}°  ·  φ {inOverview ? '——' : focus.toFixed(2)}</div>
      </div>
    </div>
  );
}

window.MindmapScene = MindmapScene;
