/* MapaEstelar.jsx — "el mapa estelar." El ecosistema como un recorrido por una
   MINI-GALAXIA con profundidad: el scroll vuela la cámara hacia adelante entre 4
   sistemas (un servicio cada uno) dispersos en el espacio. Cada sistema se acerca
   desde la lejanía (escala), se DECODIFICA (sonar, ascii-planet.js), florece su
   color de categoría, y se cruza/aleja con parallax. Starfield en warp (las
   estrellas fluyen hacia ti) + HUD fijo grande a la derecha que va cambiando de
   sistema. Ping sintetizado al fijar cada uno.

   Perf: canvas para planetas + starfield, una sola rAF para cámara/foco/estrellas,
   el HUD se actualiza por DOM (no re-render React). Fallback estático (reduced-
   motion / móvil): los 4 sistemas apilados, foco 1, sin pin. Controlable por
   window.MapaControls (calibre ASCII · densidad de estrellas · audio) — lo usa el
   panel de Tweaks. Exporta window.MapaEstelar. */
(function () {
  'use strict';

  const SYS = [
    { key: 'web',       name: 'web',        desig: 'RMX-01', cvar: '--cat-azure',  coords: 'RA 14ʰ02 · DEC +21°', solves: 'Tu sitio, rápido y hecho para que te contraten.',       seed: 11, wx: -0.52, wy: -0.52, sun: ['77%', '26%'], tools: ['figma', 'framer', 'webflow', 'next.js'] },
    { key: 'ecommerce', name: 'e-commerce', desig: 'RMX-02', cvar: '--cat-verde',  coords: 'RA 09ʰ41 · DEC −12°', solves: 'Tu tienda vendiendo desde el primer día.', seed: 23, wx: 0.64,  wy: 0.50,  sun: ['23%', '30%'], tools: ['shopify', 'stripe', 'mercado pago'] },
    { key: 'brand',     name: 'marca',      desig: 'RMX-03', cvar: '--cat-sienna', coords: 'RA 18ʰ27 · DEC +05°', solves: 'Una marca que te hace ver en grande.',           seed: 31, wx: -0.60, wy: 0.46,  sun: ['79%', '72%'], tools: ['illustrator', 'photoshop', 'blender'] },
    { key: 'continuo',  name: 'redes',   desig: 'RMX-04', cvar: '--cat-yellow', coords: 'RA 22ʰ15 · DEC −33°', solves: 'Tu sitio y tus redes con vida, mes con mes.',           seed: 44, wx: 0.56,  wy: -0.48, sun: ['25%', '70%'], tools: ['notion', 'slack', 'linear'] },
  ];
  // posiciones de los chips de herramientas alrededor del planeta (sobre el plano
  // orbital): cada servicio gana su "aura" de marcas relacionadas al enfocarse.
  const AURA_POS = [['12%', '30%'], ['90%', '44%'], ['38%', '90%'], ['74%', '14%']];
  const N = SYS.length;
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const lerp = (a, b, t) => a + (b - a) * t;

  /* i18n del mapa — nombres/solves EN + etiquetas. Default ES (México). */
  const SYS_EN = {
    web:       { name: 'web',        solves: 'Your site — fast and built to get you hired.' },
    ecommerce: { name: 'e-commerce', solves: 'Your store, selling from day one.' },
    brand:     { name: 'brand',      solves: 'A brand that makes you look bigger.' },
    continuo:  { name: 'ongoing',    solves: 'Your site and social, alive month to month.' },
  };
  const LBL = {
    es: { eyebrow: 'lo que ofrecemos · 4 servicios', title: 'nuestros servicios', leadA: 'Cada planeta es un servicio.', leadB: ' Cuatro formas de trabajar con nosotros — vuela y elige la tuya.', chart: 'carta de servicios', sector: 'MXLI · B.C · sector punto', sys: 'servicio', scan: 'escaneo', enter: 'ver este servicio', hint: 'scroll', enterCue: 'entrando a los servicios' },
    en: { eyebrow: 'what we offer · 4 services', title: 'our services', leadA: 'Each planet is a service.', leadB: ' Four ways to work with us — fly through and pick yours.', chart: 'service chart', sector: 'MXLI · B.C · dot sector', sys: 'service', scan: 'scan', enter: 'view this service', hint: 'scroll', enterCue: 'entering the services' },
  };

  // ── estado de tweaks (lo escribe el panel; defaults aquí) ────────────────
  function readLS(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : v; } catch (e) { return d; } }
  const T = {
    calibre: readLS('mapa_calibre', 'halftone'),
    stars: parseFloat(readLS('mapa_stars', '1')),
    audio: readLS('mapa_audio', '1') === '1',
  };

  // ── ping sintetizado (un "lock" discreto, hermano del thock del keycap) ──
  let actx = null;
  function ensureCtx() { if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} } if (actx && actx.state === 'suspended') actx.resume(); return actx; }
  function ping(key) {
    if (!T.audio) return;
    const ac = ensureCtx(); if (!ac) return;
    const now = ac.currentTime;
    const base = { web: 384, ecommerce: 456, brand: 540, continuo: 624 }[key] || 480;
    const master = ac.createGain(); master.gain.value = 0.16; master.connect(ac.destination);
    const o = ac.createOscillator(); o.type = 'triangle'; o.frequency.setValueAtTime(base, now);
    o.frequency.exponentialRampToValueAtTime(base * 1.5, now + 0.09);
    const g = ac.createGain();
    g.gain.setValueAtTime(0.0001, now);
    g.gain.exponentialRampToValueAtTime(1, now + 0.012);
    g.gain.exponentialRampToValueAtTime(0.0001, now + 0.42);
    const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600;
    o.connect(g); g.connect(lp); lp.connect(master); o.start(now); o.stop(now + 0.45);
    const nb = ac.createBuffer(1, ac.sampleRate * 0.01, ac.sampleRate); const d = nb.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 3);
    const ns = ac.createBufferSource(); ns.buffer = nb; const ng = ac.createGain(); ng.gain.value = 0.12;
    const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3200;
    ns.connect(hp); hp.connect(ng); ng.connect(master); ns.start(now);
  }

  function Cross() {
    return (
      <div className="sys-cross" aria-hidden="true">
        <i className="tk tk-t" /><i className="tk tk-b" /><i className="tk h tk-l" /><i className="tk h tk-r" />
        <i className="cdot" />
      </div>
    );
  }

  function MapaEstelar() {
    const wrapRef = React.useRef(null);
    const stageRef = React.useRef(null);
    const starRef = React.useRef(null);
    const warpRef = React.useRef(null);
    const warpLayerRef = React.useRef(null);
    const cueWarpRef = React.useRef(null);
    const stickyRef = React.useRef(null);
    const sysRefs = React.useRef([]);
    const canRefs = React.useRef([]);
    const railRefs = React.useRef([]);
    const trailRef = React.useRef(null);
    const cornerDesigRef = React.useRef(null);
    const cornerDotRef = React.useRef(null);
    // HUD fijo (fly-through) — refs para actualizar por DOM
    const hudRef = React.useRef(null);
    const hIdx = React.useRef(null), hName = React.useRef(null), hCoords = React.useRef(null), hSolves = React.useRef(null), hScan = React.useRef(null), hPct = React.useRef(null), hCta = React.useRef(null);

    const reduce = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const [staticMode] = React.useState(reduce);   // móvil ya NO cae a estático: también vuela la galaxia
    const [lang, setLangS] = React.useState((typeof window !== 'undefined' && window.rmGetLang) ? window.rmGetLang() : 'es');
    const narrowFly = typeof window !== 'undefined' && !reduce && window.matchMedia('(max-width: 760px)').matches;
    const L = LBL[lang] || LBL.es;
    const sysName = (s) => lang === 'en' ? SYS_EN[s.key].name : s.name;
    const sysSolves = (s) => lang === 'en' ? SYS_EN[s.key].solves : s.solves;

    React.useEffect(() => {
      const onLang = (e) => setLangS((e && e.detail) || (window.rmGetLang ? window.rmGetLang() : 'es'));
      window.addEventListener('rm-lang', onLang);
      return () => window.removeEventListener('rm-lang', onLang);
    }, []);

    React.useEffect(() => {
      const planets = SYS.map((s, i) => new window.AsciiPlanet(canRefs.current[i], { calibre: T.calibre, color: window.AsciiPlanet.cssColor('var(' + s.cvar + ')'), colorAmt: 0.96, seed: s.seed, size: 260 }));
      let planetsRunning = false;

      window.MapaControls = {
        setCalibre(c) { T.calibre = c; planets.forEach((p) => p.setCalibre(c)); try { localStorage.setItem('mapa_calibre', c); } catch (e) {} window.dispatchEvent(new CustomEvent('rm-calibre', { detail: c })); },
        setStars(v) { T.stars = v; try { localStorage.setItem('mapa_stars', String(v)); } catch (e) {} buildStars(); },
        setAudio(on) { T.audio = !!on; try { localStorage.setItem('mapa_audio', on ? '1' : '0'); } catch (e) {} },
        getState() { return { calibre: T.calibre, stars: T.stars, audio: T.audio }; },
      };

      // tamaño de render del planeta (la escala 3D la pone el transform CSS)
      function layoutPlanets() {
        if (!stageRef.current) return;
        const sz = Math.round(clamp(Math.min(stageRef.current.clientWidth, stageRef.current.clientHeight) * 0.39, 190, 400));
        planets.forEach((p, i) => {
          const el = sysRefs.current[i]; if (!el) return;
          el.style.width = sz + 'px'; el.style.height = sz + 'px';
          canRefs.current[i].style.width = sz + 'px'; canRefs.current[i].style.height = sz + 'px';
          p.resize(sz);
        });
      }

      // ── starfield en WARP (estrellas fluyen del centro hacia afuera) ──
      const sc = starRef.current, sctx = sc && sc.getContext('2d');
      let stars = [], SW = 0, SH = 0, DPR = Math.min(window.devicePixelRatio || 1, 1.5);
      function buildStars() {
        if (!sc) return;
        SW = sc.clientWidth; SH = sc.clientHeight;
        sc.width = SW * DPR; sc.height = SH * DPR; sctx.setTransform(DPR, 0, 0, DPR, 0, 0);
        const count = Math.round((SW * SH / 7000) * T.stars);
        stars = [];
        for (let i = 0; i < count; i++) stars.push({ x: (Math.random() - 0.5) * 2, y: (Math.random() - 0.5) * 2, z: Math.random() * 0.9 + 0.1, tw: Math.random() * 6.28 });
      }
      buildStars();

      function paintStars(now, vel, fx, fy) {
        if (!sctx) return;
        sctx.clearRect(0, 0, SW, SH);
        const t = (now - t0) / 1000;
        const cx = SW / 2 + fx * SW * 0.04, cy = SH / 2 + fy * SH * 0.04;
        const k = Math.min(SW, SH) * 0.62;
        const speed = (0.018 + Math.min(0.5, vel * 7)) * Math.min(0.05, dt) * 60;
        for (let i = 0; i < stars.length; i++) {
          const s = stars[i];
          s.z -= speed * 0.016;
          if (s.z <= 0.02) { s.z = 1; s.x = (Math.random() - 0.5) * 2; s.y = (Math.random() - 0.5) * 2; }
          const inv = 1 / s.z;
          const px = cx + s.x * inv * k * 0.5;
          const py = cy + s.y * inv * k * 0.5;
          if (px < -20 || px > SW + 20 || py < -20 || py > SH + 20) continue;
          const b = 1 - s.z;
          const tw = 0.65 + 0.35 * Math.sin(t * 1.6 + s.tw);
          const r = (0.4 + b * 1.7) * tw;
          sctx.fillStyle = 'rgba(232,228,216,' + (0.06 + b * 0.5).toFixed(3) + ')';
          sctx.beginPath(); sctx.arc(px, py, Math.max(0.3, r), 0, 6.2832); sctx.fill();
        }
      }

      // ── WARP de entrada (hiperespacio → iris) — fusionado del antiguo portal ──
      // El recorrido completo es UNA sola sección: primero volamos por el
      // hiperespacio (una constelación de los 4 colores se arma, el punto sienna
      // late y se abre como un iris) y ese MISMO scroll desemboca, sin costura, en
      // el fly-through de los 4 sistemas. Sin sección aparte que "se pase".
      const WARP_END = 0.26;
      const smoothstep = (t) => t * t * (3 - 2 * t);
      const WCATS = ['#477AB3', '#618E48', '#BA6849', '#D2B440'];
      const WCREAM = '#f5f4ef', WSIENNA = '#BA6849';
      const wc = warpRef.current, wctx = wc && wc.getContext('2d');
      let wStars = [], wW = 0, wH = 0, wDpr = 1, wCx = 0, wCy = 0, wMaxR = 1;
      function hexA(hex, a) { const n = parseInt(hex.slice(1), 16); return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + clamp(a, 0, 1) + ')'; }
      function mkWStar(r0) { const roll = Math.random(); return { a: Math.random() * Math.PI * 2, r: r0, spd: 0.16 + Math.random() * 0.5, z: 0.4 + Math.random() * 0.6, col: roll > 0.9 ? WCATS[(Math.random() * 4) | 0] : WCREAM }; }
      function buildWStars() { if (!wc) return; const area = (wW * wH) / (wDpr * wDpr); const n = clamp(Math.round(area / 5200), 120, 420); wStars = new Array(n).fill(0).map(() => mkWStar(Math.random())); }
      function resizeWarp() { if (!wc) return; const r = wc.getBoundingClientRect(); wDpr = Math.min(2, window.devicePixelRatio || 1); wW = wc.width = Math.max(1, Math.round(r.width * wDpr)); wH = wc.height = Math.max(1, Math.round(r.height * wDpr)); wCx = wW / 2; wCy = wH / 2; wMaxR = Math.hypot(wW, wH) / 2 * 1.06; buildWStars(); }
      const WNODES = [
        { c: WSIENNA, a: -Math.PI / 2 },
        { c: WCATS[0], a: -Math.PI / 2 + (Math.PI * 2) / 5 },
        { c: WCATS[1], a: -Math.PI / 2 + (Math.PI * 2) * 2 / 5 },
        { c: WCATS[2], a: -Math.PI / 2 + (Math.PI * 2) * 3 / 5 },
        { c: WCATS[3], a: -Math.PI / 2 + (Math.PI * 2) * 4 / 5 },
      ];
      function drawWarp(now, pp, dtw) {
        if (!wctx) return;
        const pArr = smoothstep(clamp(pp / 0.12, 0, 1));
        const pCon = smoothstep(clamp((pp - 0.05) / 0.30, 0, 1));
        const warp = smoothstep(clamp((pp - 0.32) / 0.44, 0, 1));
        const pIr = smoothstep(clamp((pp - 0.72) / 0.28, 0, 1));
        const conA = pCon * (1 - smoothstep(clamp((warp - 0.35) / 0.5, 0, 1)));
        const rot = now * 0.00007;
        const dotBase = Math.min(wW, wH) * 0.045;
        wctx.clearRect(0, 0, wW, wH);
        wctx.globalCompositeOperation = 'lighter';
        const push = (0.05 + warp * 3.1) * dtw * 60;
        for (let i = 0; i < wStars.length; i++) {
          const s = wStars[i];
          s.r += s.spd * push * 0.01;
          if (s.r >= 1) { Object.assign(s, mkWStar(Math.random() * 0.1)); continue; }
          const rr = s.r * s.r * wMaxR;
          const ca = Math.cos(s.a), sa = Math.sin(s.a);
          const x = wCx + ca * rr, y = wCy + sa * rr;
          const streak = warp * 78 * s.r * wDpr;
          const tw = 0.55 + 0.45 * Math.sin(now * 0.004 + i);
          const base = (0.18 + 0.5 * pArr) * tw;
          const a = clamp(s.r * 1.3, 0, 1) * (base + 0.5 * warp);
          if (streak > 2) {
            wctx.strokeStyle = hexA(s.col, a * 0.9); wctx.lineWidth = Math.max(1, 1.7 * s.z * wDpr); wctx.lineCap = 'round';
            wctx.beginPath(); wctx.moveTo(wCx + ca * (rr - streak), wCy + sa * (rr - streak)); wctx.lineTo(x, y); wctx.stroke();
          } else {
            wctx.fillStyle = hexA(s.col, a); const dotR = Math.max(1, 1.5 * s.z * wDpr);
            wctx.beginPath(); wctx.arc(x, y, dotR, 0, Math.PI * 2); wctx.fill();
          }
        }
        const Rc = Math.min(wW, wH) * 0.24 * (0.28 + 0.72 * pCon) * (1 + warp * 0.55);
        if (conA > 0.01) {
          const pts = WNODES.map((nd) => [wCx + Math.cos(nd.a + rot) * Rc, wCy + Math.sin(nd.a + rot) * Rc]);
          for (let i = 1; i < pts.length; i++) { wctx.strokeStyle = hexA(WNODES[i].c, conA * 0.4); wctx.lineWidth = Math.max(1, wDpr); wctx.beginPath(); wctx.moveTo(wCx, wCy); wctx.lineTo(pts[i][0], pts[i][1]); wctx.stroke(); }
          wctx.strokeStyle = hexA(WCREAM, conA * 0.16); wctx.lineWidth = Math.max(1, wDpr); wctx.beginPath();
          for (let i = 1; i < pts.length; i++) { const a = pts[i], b = pts[i + 1 > pts.length - 1 ? 1 : i + 1]; wctx.moveTo(a[0], a[1]); wctx.lineTo(b[0], b[1]); }
          wctx.closePath(); wctx.stroke();
          for (let i = 1; i < pts.length; i++) { const f = ((now * 0.0004 + i * 0.23) % 1); const px = lerp(wCx, pts[i][0], f), py = lerp(wCy, pts[i][1], f); wctx.fillStyle = hexA(WNODES[i].c, conA * (1 - f) * 0.9); wctx.beginPath(); wctx.arc(px, py, 2.4 * wDpr, 0, Math.PI * 2); wctx.fill(); }
          for (let i = 1; i < pts.length; i++) { const tw = 0.7 + 0.3 * Math.sin(now * 0.003 + i * 1.7); wctx.fillStyle = hexA(WNODES[i].c, conA * tw); wctx.beginPath(); wctx.arc(pts[i][0], pts[i][1], 3.8 * wDpr, 0, Math.PI * 2); wctx.fill(); wctx.fillStyle = hexA(WNODES[i].c, conA * 0.16 * tw); wctx.beginPath(); wctx.arc(pts[i][0], pts[i][1], 11 * wDpr, 0, Math.PI * 2); wctx.fill(); }
        }
        if (pIr < 0.001) {
          const dy = lerp(-wH * 0.02, 0, pArr);   // arranca centrado: recibe el punto del manifiesto sin salto
          const breathe = 1 + 0.06 * Math.sin(now * 0.004) * pArr;
          const dotR = dotBase * (0.6 + 0.4 * pArr) * (1 + warp * 0.6) * breathe;
          wctx.fillStyle = hexA(WSIENNA, 0.6 * (0.55 + 0.45 * pArr)); wctx.beginPath(); wctx.arc(wCx, wCy + dy, dotR * 2.6, 0, Math.PI * 2); wctx.fill();
          wctx.globalCompositeOperation = 'source-over';
          wctx.fillStyle = WSIENNA; wctx.beginPath(); wctx.arc(wCx, wCy + dy, dotR, 0, Math.PI * 2); wctx.fill();
          wctx.fillStyle = hexA(WCREAM, 0.85 * pArr); wctx.beginPath(); wctx.arc(wCx, wCy + dy, dotR * 0.32, 0, Math.PI * 2); wctx.fill();
        }
        wctx.globalCompositeOperation = 'source-over';
        if (pIr > 0.001) {
          const flash = Math.exp(-Math.pow((pp - 0.72) / 0.05, 2)) * 0.5;
          if (flash > 0.01) { wctx.fillStyle = hexA(WCREAM, flash); wctx.fillRect(0, 0, wW, wH); }
          const seed = dotBase * (1 + warp * 0.6);
          const Rout = lerp(seed, wMaxR * 1.5, pIr);
          const Rin = lerp(0, wMaxR * 1.5, smoothstep(clamp((pIr - 0.16) / 0.84, 0, 1)));
          wctx.beginPath(); wctx.arc(wCx, wCy, Rout, 0, Math.PI * 2); wctx.arc(wCx, wCy, Math.max(0, Rin), 0, Math.PI * 2, true);
          wctx.fillStyle = WSIENNA; wctx.fill('evenodd');
          if (Rin > 1 && Rin < wMaxR * 1.5) { wctx.strokeStyle = hexA(WCREAM, (1 - pIr) * 0.7); wctx.lineWidth = 2 * wDpr; wctx.beginPath(); wctx.arc(wCx, wCy, Rin, 0, Math.PI * 2); wctx.stroke(); }
        }
      }
      if (wc) resizeWarp();

      let sw = 0, sh = 0;
      function layoutSystems() {
        if (!stageRef.current) return;
        sw = stageRef.current.clientWidth; sh = stageRef.current.clientHeight;
        layoutPlanets();
      }
      layoutSystems();

      let camT = 0, camX = 0, camY = 0, prevCamT = 0, lock = -1, hudShown = -1, raf = 0, t0 = performance.now(), dt = 0.016, last = t0;
      let isNarrow = window.matchMedia('(max-width: 760px)').matches;
      function applyNarrow() { if (wrapRef.current) wrapRef.current.classList.toggle('mapa--narrow', isNarrow && !staticMode); }
      applyNarrow();

      function pathAt(t) {
        const i0 = clamp(Math.floor(t), 0, N - 1), i1 = clamp(i0 + 1, 0, N - 1), fr = clamp(t - i0, 0, 1);
        return [lerp(SYS[i0].wx, SYS[i1].wx, fr), lerp(SYS[i0].wy, SYS[i1].wy, fr)];
      }

      function swapHud(i) {
        const s = SYS[i];
        if (hudRef.current) hudRef.current.style.setProperty('--sys-c', 'var(' + s.cvar + ')');
        if (trailRef.current) trailRef.current.style.setProperty('--sys-c', 'var(' + s.cvar + ')');
        let slug = s.key;
        if (s.key === 'continuo') slug = 'redes';
        if (s.key === 'brand') slug = 'marca';
        if (hIdx.current) hIdx.current.innerHTML = L.sys + ' <span class="tnum">' + String(i + 1).padStart(2, '0') + '</span> / 0' + N;
        if (hName.current) hName.current.innerHTML = '<span>' + sysName(s) + '</span><i class="dot" aria-hidden="true"></i>';
        if (hSolves.current) hSolves.current.textContent = sysSolves(s);
        if (hCta.current) hCta.current.setAttribute('href', '/' + slug);
      }

      function loop(now) {
        dt = Math.min(0.05, (now - last) / 1000); last = now;
        let near = true;
        if (!staticMode && wrapRef.current) {
          const wr0 = wrapRef.current.getBoundingClientRect();
          near = wr0.bottom > -400 && wr0.top < window.innerHeight + 400;
        }
        if (near && !planetsRunning) { planets.forEach((p) => p.start()); planetsRunning = true; }
        else if (!near && planetsRunning) { planets.forEach((p) => p.stop()); planetsRunning = false; }
        if (!near) { raf = requestAnimationFrame(loop); return; }

        if (staticMode) { if (T.stars > 0) paintStars(now, 0, 0, 0); raf = requestAnimationFrame(loop); return; }

        const wrap = wrapRef.current;
        const wr = wrap.getBoundingClientRect();
        const total = wrap.offsetHeight - window.innerHeight;
        const praw = (typeof window.__mapaProgress === 'number') ? window.__mapaProgress : (total > 0 ? clamp(-wr.top / total, 0, 1) : 0);

        // ── fase WARP (un solo scroll): el primer tramo es el hiperespacio + iris.
        // pp 0→1 a lo largo de [0, WARP_END]; el iris (pIris) revela la galaxia.
        const pp = clamp(praw / WARP_END, 0, 1);
        const pIris = smoothstep(clamp((pp - 0.72) / 0.28, 0, 1));   // 0 en warp · 1 con la galaxia abierta
        const warpPull = (1 - pIris) * 0.9;                          // empuja el 1er sistema lejos en warp → entra al abrir el iris
        if (stickyRef.current) stickyRef.current.style.setProperty('--mapa-chrome', pIris.toFixed(3));
        if (wc) {
          if (praw < WARP_END + 0.02) {
            drawWarp(now, pp, dt);
            if (warpLayerRef.current) {
              warpLayerRef.current.style.display = '';
              warpLayerRef.current.style.opacity = (1 - smoothstep(clamp((pp - 0.86) / 0.14, 0, 1))).toFixed(3);
            }
            if (cueWarpRef.current) {
              const ca = clamp((pp - 0.02) / 0.08, 0, 1) * (1 - clamp((pp - 0.66) / 0.12, 0, 1));
              cueWarpRef.current.style.opacity = ca.toFixed(3);
            }
          } else if (warpLayerRef.current && warpLayerRef.current.style.display !== 'none') {
            wctx && wctx.clearRect(0, 0, wW, wH);
            warpLayerRef.current.style.display = 'none';
          }
        }

        // lead/tail: el primer y ÚLTIMO sistema reciben dwell centrado antes de que
        // la sección se despinne. El fly-through ocupa [WARP_END, 1] del track.
        const flyRaw = clamp((praw - WARP_END) / (1 - WARP_END), 0, 1);
        const LEAD = 0.05, TAIL = 0.2;
        const p = clamp((flyRaw - LEAD) / (1 - LEAD - TAIL), 0, 1);
        const targetRaw = p * (N - 1);
        // escalera con DWELL: cada sistema queda CLAVADO en el centro un 28% del
        // tramo a cada lado (así e-commerce y continuo, que vienen de la derecha,
        // llegan completos y reposan centrados igual que brand); el viaje entre
        // sistemas usa smoothstep.
        const seg = clamp(Math.floor(targetRaw), 0, N - 2);
        const frRaw = clamp(targetRaw - seg, 0, 1);
        const DW = 0.28;
        const frT = clamp((frRaw - DW) / (1 - 2 * DW), 0, 1);
        const target = seg + frT * frT * (3 - 2 * frT);
        prevCamT = camT;
        // cámara SIN lag: el sistema enfocado aterriza EXACTO en su marca. El
        // smooth-scroll global da la suavidad. warpPull empuja el 1er sistema atrás
        // durante el warp, así entra de golpe cuando el iris se abre.
        camT = target - warpPull;
        const vel = Math.abs(camT - prevCamT);
        const [pxw, pyw] = pathAt(camT);
        camX = pxw; camY = pyw;

        const cx = sw * (isNarrow ? 0.5 : 0.28), cy = sh * (isNarrow ? 0.36 : 0.5);          // planeta en la mitad IZQUIERDA; el HUD respira en la derecha
        const spread = Math.min(sw, sh) * 0.92;
        const F = 1.5;

        if (T.stars > 0) paintStars(now, vel, camX, camY);

        for (let i = 0; i < N; i++) {
          const rz = i - camT;
          const ap = F / (F + Math.max(rz, -0.78));
          const scale = clamp(ap, 0.04, 2.8);
          const ox = (SYS[i].wx - camX) * spread * ap;
          const oy = (SYS[i].wy - camY) * spread * ap * 0.86;
          let op = 1;
          if (rz > 0.7) op = clamp((2.1 - rz) / 1.4, 0, 1);
          if (rz < -0.15) op = clamp((rz + 0.85) / 0.7, 0, 1);
          const el = sysRefs.current[i]; if (!el) continue;
          el.style.transform = 'translate(' + (cx + ox).toFixed(1) + 'px,' + (cy + oy).toFixed(1) + 'px) translate(-50%,-50%) scale(' + scale.toFixed(3) + ')';
          el.style.opacity = (op * pIris).toFixed(3);
          el.style.zIndex = String(Math.round(1500 - rz * 100));
          el.classList.toggle('is-focus', Math.abs(rz) < 0.42);
          planets[i].setFocus(clamp(1 - Math.abs(rz) / 0.92, 0, 1));
        }

        // HUD fijo: sigue al sistema más cercano; cross-fade al cambiar
        const nearest = clamp(Math.round(camT), 0, N - 1);
        const fHud = clamp(1 - Math.abs(nearest - camT) / 0.5, 0, 1);
        if (nearest !== hudShown && fHud < 0.55) { hudShown = nearest; swapHud(nearest); }
        if (hudRef.current) hudRef.current.style.opacity = ((0.12 + 0.88 * fHud) * pIris).toFixed(3);
        const pct = Math.round(clamp(1 - Math.abs(nearest - camT) / 0.92, 0, 1) * 100);
        if (hScan.current) hScan.current.style.setProperty('--scan', pct + '%');
        if (hPct.current) hPct.current.textContent = pct + '%';

        if (Math.abs(nearest - camT) < 0.03 && nearest !== lock) {
          lock = nearest; ping(SYS[nearest].key);
          // garantía: el HUD siempre coincide con el sistema fijado, aunque el
          // scroll haya saltado el tramo de viaje en un frame (PageDown, drag
          // de scrollbar, flick) y swapHud no se disparara en el cross-fade.
          if (hudShown !== nearest) { hudShown = nearest; swapHud(nearest); }
          const s = SYS[nearest];
          if (cornerDesigRef.current) cornerDesigRef.current.textContent = s.desig;
          if (cornerDotRef.current) cornerDotRef.current.style.setProperty('--sys-cat', 'var(' + s.cvar + ')');
          railRefs.current.forEach((rb, k) => { if (rb) rb.classList.toggle('on', k === nearest); });
        }
        raf = requestAnimationFrame(loop);
      }

      if (staticMode) {
        planets.forEach((p, i) => { p.start(); p.setFocus(1); p._focusEased = 0.4; const el = sysRefs.current[i]; if (el) el.classList.add('is-focus'); });
        planetsRunning = true;
        railRefs.current.forEach((rb, i) => { if (rb) rb.classList.toggle('on', i === 0); });
      } else {
        swapHud(0); hudShown = 0;
      }
      raf = requestAnimationFrame(loop);

      const onResize = () => { isNarrow = window.matchMedia('(max-width: 760px)').matches; applyNarrow(); buildStars(); resizeWarp(); layoutSystems(); };
      window.addEventListener('resize', onResize);
      const unlock = () => ensureCtx();
      window.addEventListener('pointerdown', unlock, { once: true });

      return () => {
        cancelAnimationFrame(raf);
        window.removeEventListener('resize', onResize);
        window.removeEventListener('pointerdown', unlock);
        planets.forEach((p) => p.destroy());
        if (window.MapaControls) delete window.MapaControls;
      };
    }, [staticMode, lang]);

    function jumpTo(i) {
      if (staticMode) {
        const el = sysRefs.current[i]; if (!el) return;
        const r = el.getBoundingClientRect();
        window.scrollTo({ top: r.top + window.pageYOffset - (window.innerHeight - r.height) / 2, behavior: 'smooth' });
        return;
      }
      const wrap = wrapRef.current; if (!wrap) return;
      const total = wrap.offsetHeight - window.innerHeight;
      const y = wrap.offsetTop + (i / (N - 1)) * total;
      window.scrollTo({ top: y, behavior: 'smooth' });
    }

    return (
      <section id="mapa" className={'mapa' + (staticMode ? ' static' : '')} ref={wrapRef} style={{ '--mapa-largo': narrowFly ? 6.5 : 9 }}>
        <div className="mapa-sticky" ref={stickyRef}>
          <canvas className="mapa-stars" ref={starRef} aria-hidden="true" />
          {!staticMode && (
            <div className="mapa-warp-layer" ref={warpLayerRef} aria-hidden="true">
              <canvas className="mapa-warp" ref={warpRef} />
              <span className="mapa-warp-cue" ref={cueWarpRef}>
                <span className="mapa-warp-txt">{L.enterCue} <span className="mapa-warp-sub">· {L.chart}</span></span>
                <i className="mapa-warp-ar" aria-hidden="true" />
              </span>
            </div>
          )}

          <div className="mapa-titleblock">
            <h2 className="mapa-title">{L.title}<span className="dot">.</span></h2>
            <p className="mapa-lead"><b>{L.leadA}</b>{L.leadB}</p>
          </div>

          <div className="mapa-stage" ref={stageRef}>
            <div className="mapa-plane">
              {SYS.map((s, i) => (
                <div className={'sys sys--' + s.key} key={s.key} ref={(el) => (sysRefs.current[i] = el)} style={{ '--sys-c': 'var(' + s.cvar + ')' }}>
                  <i className="sys-glow" aria-hidden="true" />
                  <i className="sys-vibe" aria-hidden="true" />
                  <i className="sys-orbit-2" aria-hidden="true" />
                  <i className="sys-orbit" aria-hidden="true" />
                  <i className="sys-sun" aria-hidden="true" style={{ '--sun-left': s.sun[0], '--sun-top': s.sun[1] }} />
                  <canvas className="sys-planet" ref={(el) => (canRefs.current[i] = el)} aria-hidden="true" />
                  <Cross />
                  <div className="sys-aura" aria-hidden="true">
                    {s.tools.map((tool, k) => (
                      <span className="aura-chip" key={k} style={{ left: AURA_POS[k][0], top: AURA_POS[k][1], '--d': (k * 0.12) + 's' }}>
                        <i className="aura-dot" />{tool}
                      </span>
                    ))}
                  </div>
                  {staticMode && (
                    <div className="sys-hud-static">
                      <p className="mh-idx">{L.sys} <span className="tnum">{String(i + 1).padStart(2, '0')}</span> / 0{N}</p>
                      <p className="mh-name">{sysName(s)}<span className="dot" aria-hidden="true" /></p>
                      <p className="mh-solves">{sysSolves(s)}</p>
                      <p className="mh-cta"><a href={'/' + (s.key === 'continuo' ? 'redes' : s.key === 'brand' ? 'marca' : s.key)}>{L.enter}<span className="arr" aria-hidden="true"><i /><i /><i /></span></a></p>
                    </div>
                  )}
                </div>
              ))}
            </div>
          </div>

          {!staticMode && (
            <div className="mapa-hud" ref={hudRef}>
              <p className="mh-idx" ref={hIdx}>{L.sys} <span className="tnum">01</span> / 0{N}</p>
              <h3 className="mh-name" ref={hName}><span>{sysName(SYS[0])}</span><i className="dot" aria-hidden="true" /></h3>
              <div className="mh-rule" aria-hidden="true" />
              <p className="mh-solves" ref={hSolves}>{sysSolves(SYS[0])}</p>
              <p className="mh-cta"><a ref={hCta} href={'/web'}>{L.enter}<span className="arr" aria-hidden="true"><i /><i /><i /></span></a></p>
            </div>
          )}

          <div className="mapa-trail" ref={trailRef} aria-hidden="true">
            <span className="mt-beam"><i className="mt-bead" /></span>
            <span className="mt-lbl">{L.hint}</span>
          </div>
        </div>
      </section>
    );
  }

  window.MapaEstelar = MapaEstelar;
})();
