// op-globe-light.jsx — rotating 3D globe with production node markers + great-circle arcs.
// Canvas-rendered surface for performance; thin SVG overlay for the active label.
// Respects the Optimum light palette (alabaster / steel / platinum / midnight / azure).

// ─── Validator catalogue ────────────────────────────────────────
// Sourced from the single source of truth (services/validators.js). No longer
// rendered on this globe (see "Production node locations" below) — kept only
// so search-driven focus (activeId/focusId, wired from op-hub-light.jsx) can
// still rotate the camera toward a validator's lat/lon.
const VALIDATORS = (typeof window !== 'undefined' && window.OptimumValidators)
  ? window.OptimumValidators.getLocated()
  : [];

// ─── Optimum brand mark ─────────────────────────────────────────
// The loop logomark (path lifted from the brand favicon) rendered as a
// theme-coloured data-URI, so both the SVG atlas and this canvas globe can
// stamp it anywhere. On the map we swap every validator brand logo for this
// mark. The fleet-presence list below is exported for the flat atlas to stamp with
// this mark; this globe does not draw it (see "Production node locations").
const OPTIMUM_MARK_PATH = 'M108.643 30.8574C99.8582 30.8574 91.7205 33.6604 85.0594 38.4324C78.8114 42.9074 70.4552 42.695 64.1792 38.2638C57.0498 33.2314 48.2531 30.4404 38.7937 30.9054C17.9253 31.932 1.02271 49.0348 0.0461766 70.1157C-1.05341 93.7989 17.6276 113.348 40.8299 113.348C49.6147 113.348 57.7565 110.545 64.4134 105.773C70.6577 101.298 79.0177 101.51 85.2936 105.941C92.4272 110.974 101.22 113.765 110.683 113.3C131.548 112.277 148.45 95.1703 149.431 74.0895C150.53 50.4062 131.846 30.8574 108.643 30.8574ZM129.992 73.3757C129.694 79.8279 127.006 85.9351 122.426 90.5667C117.845 95.2023 111.807 97.9251 105.42 98.2419C104.987 98.2619 104.546 98.2741 104.114 98.2741C98.7784 98.2741 93.6577 96.6499 89.3069 93.5783C84.9563 90.5107 79.883 88.8865 74.6273 88.8865C69.3715 88.8865 64.5047 90.4705 60.3169 93.4699C55.9303 96.6099 50.7581 98.2699 45.3593 98.2699C38.2101 98.2699 31.553 95.3909 26.6109 90.1617C21.6766 84.9367 19.144 78.0715 19.4814 70.8253C19.7791 64.3733 22.4666 58.2658 27.0475 53.6344C31.6285 48.9988 37.6663 46.276 44.0533 45.9632C44.4859 45.943 44.9266 45.931 45.3593 45.931C50.6944 45.931 55.8153 47.5552 60.166 50.6268C64.5128 53.6944 69.5898 55.3186 74.8457 55.3186C80.0221 55.3186 84.9682 53.7346 89.1561 50.735C93.5425 47.5952 98.715 45.935 104.114 45.935C111.263 45.935 117.924 48.8142 122.862 54.0434C127.796 59.2684 130.329 66.1297 129.992 73.3757Z';
function optimumMarkDataUri(fill) {
  return 'data:image/svg+xml;utf8,' + encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="150" height="144" viewBox="0 0 150 144"><path d="' + OPTIMUM_MARK_PATH + '" fill="' + fill + '"/></svg>'
  );
}
// Cities where the Hermes fleet is deployed in production. Real locations, at city
// granularity rather than exact datacentre coordinates, and separate from the
// validator roster: these are OUR nodes, not operators'.
// TODO(live-data): hardcoded, so the map ages silently as the fleet moves. Needs a
// live deployment feed; bootstrap's discv5 registry carries no geography and
// measurements' gateway list is gateways rather than Hermes nodes.
const OPTIMUM_PRESENCE = [
  { id: 'OP-SAO', city: 'São Paulo',    region: 'South America', lon: -46.6, lat: -23.5 },
  { id: 'OP-BUE', city: 'Buenos Aires', region: 'South America', lon: -58.4, lat: -34.6 },
  { id: 'OP-BOG', city: 'Bogotá',       region: 'South America', lon: -74.1, lat: 4.7 },
  { id: 'OP-SCL', city: 'Santiago',     region: 'South America', lon: -70.7, lat: -33.4 },
  { id: 'OP-LAG', city: 'Lagos',        region: 'Africa',        lon: 3.4,   lat: 6.5 },
  { id: 'OP-NBO', city: 'Nairobi',      region: 'Africa',        lon: 36.8,  lat: -1.3 },
  { id: 'OP-CAI', city: 'Cairo',        region: 'Africa',        lon: 31.2,  lat: 30.0 },
  { id: 'OP-CPT', city: 'Cape Town',    region: 'Africa',        lon: 18.4,  lat: -33.9 },
  { id: 'OP-SYD', city: 'Sydney',       region: 'Oceania',       lon: 151.2, lat: -33.9 },
  { id: 'OP-AKL', city: 'Auckland',     region: 'Oceania',       lon: 174.8, lat: -36.8 },
  { id: 'OP-MEX', city: 'Mexico City',  region: 'North America', lon: -99.1, lat: 19.4 },
  { id: 'OP-YYZ', city: 'Toronto',      region: 'North America', lon: -79.4, lat: 43.7 },
  { id: 'OP-ORD', city: 'Chicago',      region: 'North America', lon: -87.6, lat: 41.9 },
  { id: 'OP-BOM', city: 'Mumbai',       region: 'Asia',          lon: 72.9,  lat: 19.1 },
  { id: 'OP-DXB', city: 'Dubai',        region: 'Asia',          lon: 55.3,  lat: 25.3 },
  { id: 'OP-TYO', city: 'Tokyo',        region: 'Asia',          lon: 139.7, lat: 35.7 },
  { id: 'OP-MAD', city: 'Madrid',       region: 'Europe',        lon: -3.7,  lat: 40.4 },
  { id: 'OP-ARN', city: 'Stockholm',    region: 'Europe',        lon: 18.1,  lat: 59.3 },
];
if (typeof window !== 'undefined') Object.assign(window, { OPTIMUM_MARK_PATH, optimumMarkDataUri, OPTIMUM_PRESENCE });

// Pre-rendered navy mark for canvas stamping (transparent background).
let OP_MARK_IMG = null, OP_MARK_READY = false;
if (typeof Image !== 'undefined') {
  OP_MARK_IMG = new Image();
  OP_MARK_IMG.onload = () => { OP_MARK_READY = true; };
  OP_MARK_IMG.src = optimumMarkDataUri('#282C34');
}

// ─── Continent polygons (lon, lat) ──────────────────────────────
// Loaded asynchronously from Natural Earth 110m land geometry via CDN —
// gives proper cartographic coastlines instead of cubic stand-ins.
// `CONTINENT_POLYGONS` starts empty and is populated once the fetch resolves.
const CONTINENT_POLYGONS = [];

const CONTINENT_DOTS = [];  // dot-matrix retired in favour of filled polygons

// ─── Math ────────────────────────────────────────────────────────
function lonLatToVec(lon, lat) {
  const phi = (90 - lat) * Math.PI / 180;
  const theta = (lon + 180) * Math.PI / 180;
  return {
    x: -Math.sin(phi) * Math.cos(theta),
    y:  Math.cos(phi),
    z:  Math.sin(phi) * Math.sin(theta),
  };
}
function rotY(p, a) {
  const c = Math.cos(a), s = Math.sin(a);
  return { x: p.x * c + p.z * s, y: p.y, z: -p.x * s + p.z * c };
}
function rotX(p, a) {
  const c = Math.cos(a), s = Math.sin(a);
  return { x: p.x, y: p.y * c - p.z * s, z: p.y * s + p.z * c };
}
function slerp(a, b, t) {
  const d = Math.max(-1, Math.min(1, a.x*b.x + a.y*b.y + a.z*b.z));
  const om = Math.acos(d);
  const so = Math.sin(om);
  if (so < 1e-5) return a;
  const f1 = Math.sin((1 - t) * om) / so;
  const f2 = Math.sin(t * om) / so;
  return { x: a.x*f1 + b.x*f2, y: a.y*f1 + b.y*f2, z: a.z*f1 + b.z*f2 };
}

// Pre-project continent vectors once (dots retired but kept null-safe)
const CONTINENT_VECS = CONTINENT_DOTS.map(([lon, lat]) => lonLatToVec(lon, lat));
// Continent polygon vertices, pre-projected. Mutable — populated when the
// Natural Earth land GeoJSON resolves.
let CONTINENT_POLY_VECS = [];

// Fetch Natural Earth 110m country polygons (with borders). Public-domain, ~250KB.
// Switched from ne_110m_land (single landmass outlines) so we get country-level
// boundaries we can stroke between adjacent fills.
const NE_COUNTRIES_URL = 'https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/cultural/ne_110m_admin_0_countries.json';
(function loadWorldCountries() {
  if (typeof fetch !== 'function') return;
  fetch(NE_COUNTRIES_URL)
    .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
    .then(geo => {
      const rings = [];
      for (const feat of (geo.features || [])) {
        const g = feat.geometry;
        if (!g) continue;
        if (g.type === 'Polygon') {
          // Outer ring only — holes ignored for our stylised look.
          if (g.coordinates[0]) rings.push(g.coordinates[0]);
        } else if (g.type === 'MultiPolygon') {
          for (const p of g.coordinates) if (p[0]) rings.push(p[0]);
        }
      }
      CONTINENT_POLY_VECS = rings.map(ring => ring.map(([lon, lat]) => lonLatToVec(lon, lat)));
    })
    .catch(err => console.warn('[GLOBE] world map load failed:', err));
})();
// ─── Production node locations ───────────────────────────────────
// Point-in-time snapshot of where the production propagation-node fleet
// runs, collapsed to one marker per datacenter (data/network-nodes.json —
// see its `source` field for how to regenerate). Fetched same-origin, the
// same way the country polygons above are; the render loop below reads
// these mutable arrays fresh every frame so they light up as soon as the
// fetch resolves.
let NODE_LOCATIONS = [];
let NODE_VECS = [];
const ARC_SAMPLES = 28;
let ARC_DATA = [];

// Great-circle mesh: a ring across the four highest-capacity hubs, plus
// every smaller hub arcing to its nearest major hub.
const NODE_ARC_PAIRS = [
  ['iad', 'fra'], ['fra', 'sin'], ['sin', 'nrt'], ['nrt', 'iad'],
  ['iowa', 'iad'], ['lax', 'nrt'], ['pdx', 'nrt'], ['chs', 'iad'],
  ['gru', 'iad'], ['yul', 'iad'], ['ams', 'fra'], ['cdg', 'fra'],
  ['lhr', 'fra'], ['hel', 'fra'], ['waw', 'fra'], ['syd', 'sin'],
  ['cgk', 'sin'], ['bom', 'sin'], ['icn', 'nrt'],
];

function buildNodeArcData(vecs) {
  const byId = {};
  vecs.forEach(n => { byId[n.id] = n.vec; });
  return NODE_ARC_PAIRS.map(([ai, bi]) => {
    const A = byId[ai], B = byId[bi];
    if (!A || !B) return null;
    // Lift the path slightly off the surface for visibility (bulge outward)
    const pts = [];
    for (let i = 0; i <= ARC_SAMPLES; i++) {
      const t = i / ARC_SAMPLES;
      const s = slerp(A, B, t);
      const lift = 1 + 0.08 * Math.sin(t * Math.PI);
      pts.push({ x: s.x * lift, y: s.y * lift, z: s.z * lift });
    }
    return { a: ai, b: bi, pts };
  }).filter(Boolean);
}

if (typeof fetch === 'function') {
  fetch('dashboard/data/network-nodes.json')
    .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
    .then(data => {
      NODE_LOCATIONS = data.locations || [];
      NODE_VECS = NODE_LOCATIONS.map(n => ({ ...n, vec: lonLatToVec(n.lon, n.lat) }));
      ARC_DATA = buildNodeArcData(NODE_VECS);
    })
    .catch(err => console.warn('[GLOBE] node locations load failed:', err));
}

// ─── Component ───────────────────────────────────────────────────
function targetRotationForLatLon(lat, lon) {
  const v = lonLatToVec(lon, lat);
  const s = Math.sqrt(v.x * v.x + v.z * v.z);
  return {
    rotY: Math.atan2(-v.x, v.z),
    rotX: Math.atan2(v.y, s),
  };
}

function GlobeViz({ size = 480, radiusScale = 0.405, activeId, focusId, onPick }) {
  const canvasRef = React.useRef(null);
  const hitsRef = React.useRef([]);          // {id, sx, sy, z, r}
  const stateRef = React.useRef({ rotY: -0.4, rotX: -0.18, autoRot: 0, paused: false, time: 0 });
  const activeIdRef = React.useRef(activeId);
  const hoveredRef = React.useRef(null);
  const [hovered, setHovered] = React.useState(null);
  const [labelPos, setLabelPos] = React.useState(null); // for active validator label

  // Mirror state/props into refs so the draw loop can read latest values
  // without being torn down on every change.
  React.useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
  React.useEffect(() => { hoveredRef.current = hovered; }, [hovered]);

  // Animate rotation toward the focused validator when focusId changes.
  React.useEffect(() => {
    const st = stateRef.current;
    if (!focusId) {
      // No validator focused (e.g. the search was just cleared) — return the
      // globe to its ambient rotation instead of leaving it parked on the last
      // target. Runs only when focusId changes, so it never fights a manual
      // desktop drag (that keeps focusId null throughout).
      st.userControl = false;
      st.targetRotY = undefined;
      st.targetRotX = undefined;
      return;
    }
    const v = VALIDATORS.find(x => x.id === focusId);
    if (!v) return;
    const t = targetRotationForLatLon(v.lat, v.lon);
    st.targetRotY = t.rotY;
    st.targetRotX = Math.max(-1.0, Math.min(1.0, t.rotX));
    st.userControl = true;
    st.spinVX = 0; st.spinVY = 0;
    st.paused = false;
  }, [focusId]);

  // No auto-pause: the globe should always feel alive. (Drag handlers below
  // will pause manually while the user is interacting.)

  // Releasing control when the user is plainly done with the globe.
  //
  // handleUp latches `userControl` after a mouse/pen drag so a node can be
  // clicked without the globe drifting out from under the cursor. Nothing on
  // desktop ever cleared it again — pointercancel only fires when the browser
  // steals a gesture, the focus effect only runs when focusId changes, and the
  // scroll listener below is gated to phones. So a single drag left the globe
  // parked until a reload. A pointer press anywhere off the canvas is the
  // signal that attention has moved on, so ambient rotation resumes.
  //
  // Skipped while a validator is focused: there the parked position is
  // deliberate, and the focus effect owns the release.
  React.useEffect(() => {
    if (focusId) return undefined;
    const release = (e) => {
      const cv = canvasRef.current;
      if (!cv || (e.target && cv.contains(e.target))) return;   // press was on the globe
      const st = stateRef.current;
      if (!st.userControl && !st.paused) return;
      st.userControl = false;
      st.paused = false;
      st.targetRotY = undefined;
      st.targetRotX = undefined;
    };
    // Capture phase, so a handler that stops propagation cannot swallow it.
    document.addEventListener('pointerdown', release, true);
    return () => document.removeEventListener('pointerdown', release, true);
  }, [focusId]);

  // On phones, resume the ambient "always alive" rotation as soon as the user
  // scrolls the page. A search-focus or a swipe can leave userControl latched,
  // and scrolling is the natural signal that the user is done inspecting the
  // globe — so return it to normal circulation. Scoped to the mobile layout so
  // desktop drag/focus behaviour is unchanged.
  React.useEffect(() => {
    const onScroll = () => {
      if (window.innerWidth > 480) return;
      const st = stateRef.current;
      st.userControl = false;
      st.paused = false;
      st.targetRotY = undefined;
      st.targetRotX = undefined;
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Main draw loop — set up exactly once on mount. The deps array is [] so
  // spurious re-renders from parent state churn (live ticking numbers etc.)
  // can't tear down and re-create the canvas / clear it via resize.
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    // The hub honours prefers-reduced-motion in CSS in five places, but the
    // largest animation on the page -- this canvas -- was exempt. Freeze the
    // ambient drift only, NOT the loop: hover, focus and the theme swap all
    // reach the canvas through this rAF body.
    const reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    let lastW = 0, lastH = 0;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const w = Math.max(1, Math.round(rect.width));
      const h = Math.max(1, Math.round(rect.height));
      if (w === lastW && h === lastH) return;
      lastW = w; lastH = h;
      canvas.width  = w * dpr;
      canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);

    // Pause the (main-thread-heavy) redraw while the globe is scrolled out of
    // view — this keeps mobile page-scrolling smooth and saves battery. The rAF
    // loop stays alive so drawing resumes instantly when the globe returns.
    let visible = true;
    let io = null;
    if ('IntersectionObserver' in window) {
      io = new IntersectionObserver((entries) => { visible = entries[0].isIntersecting; }, { threshold: 0 });
      io.observe(canvas);
    }

    let raf;
    let last = performance.now();
    const draw = (now) => {
      raf = requestAnimationFrame(draw);
      const dt = Math.min(50, now - last); last = now;
      if (!visible) return;
      const st = stateRef.current;
      const activeId = activeIdRef.current;
      const hovered = hoveredRef.current;
      // Auto-rotate only if the user hasn't taken control. Once they drag, the
      // globe stops spinning on its own so they can click nodes precisely.
      // ~0.00018 rad/ms ≈ one full revolution every ~35s — calm, ambient pace.
      if (!reduceMotion && !st.paused && !st.userControl) st.rotY += dt * 0.00018;
      // Apply drag inertia if any
      if (st.spinVY) { st.rotY += st.spinVY * dt; st.spinVY *= Math.pow(0.92, dt / 16); if (Math.abs(st.spinVY) < 1e-5) st.spinVY = 0; }
      if (st.spinVX) { st.rotX = Math.max(-1.0, Math.min(1.0, st.rotX + st.spinVX * dt)); st.spinVX *= Math.pow(0.92, dt / 16); if (Math.abs(st.spinVX) < 1e-5) st.spinVX = 0; }

      // Ease toward search-focus target if requested.
      if (typeof st.targetRotY === 'number') {
        const k = 1 - Math.pow(0.002, dt / 1000); // ~1s easing
        // Shortest-angle delta on Y
        let dy = st.targetRotY - st.rotY;
        dy = Math.atan2(Math.sin(dy), Math.cos(dy));
        const dx = st.targetRotX - st.rotX;
        st.rotY += dy * k;
        st.rotX += dx * k;
        if (Math.abs(dy) < 0.002 && Math.abs(dx) < 0.002) {
          st.rotY = st.targetRotY;
          st.rotX = st.targetRotX;
          st.targetRotY = undefined;
          st.targetRotX = undefined;
        }
      }
      st.time += dt * 0.001;

      // Draw in the canvas's own CSS-pixel space. lastW/lastH are the exact
      // dimensions the ResizeObserver sized the backing store to, so they are
      // authoritative -- and unlike getBoundingClientRect() they cannot force a
      // style/layout flush from inside the rAF body on frames where the DOM is
      // dirty. Also fixes a resize race: ResizeObserver fires AFTER rAF, so
      // frame N used to draw new dimensions into the still-old buffer.
      const W = lastW, H = lastH;
      const R = Math.min(W, H) * radiusScale;
      const cx = W / 2, cy = H / 2;
      ctx.clearRect(0, 0, W, H);

      // Theme-aware: dim the sphere + halo in dark mode so the white globe
      // doesn't glare against the dark page (light mode is left bright).
      const darkMode = (document.documentElement.dataset.theme === 'dark');

      // Atmosphere (gentle azure haze) — softer in dark mode
      const atm = ctx.createRadialGradient(cx, cy, R * 0.98, cx, cy, R * 1.22);
      atm.addColorStop(0, darkMode ? 'rgba(81,177,254,0.09)' : 'rgba(81,177,254,0.16)');
      atm.addColorStop(0.55, darkMode ? 'rgba(81,177,254,0.03)' : 'rgba(81,177,254,0.055)');
      atm.addColorStop(1, 'rgba(81,177,254,0)');
      ctx.fillStyle = atm;
      ctx.beginPath(); ctx.arc(cx, cy, R * 1.22, 0, Math.PI * 2); ctx.fill();

      // Faint cast shadow — keeps the globe grounded without weight
      ctx.fillStyle = 'rgba(40,44,52,0.035)';
      ctx.beginPath(); ctx.ellipse(cx, cy + R * 0.94, R * 0.50, R * 0.075, 0, 0, Math.PI * 2); ctx.fill();

      // Sphere base — near-uniform brand grey; dimmer, non-glaring in dark mode
      const sph = ctx.createRadialGradient(cx - R * 0.32, cy - R * 0.42, R * 0.05, cx, cy, R * 1.05);
      if (darkMode) {
        sph.addColorStop(0, '#DBE1E8');
        sph.addColorStop(0.55, '#C0CAD5');
        sph.addColorStop(0.92, '#9BA9B9');
        sph.addColorStop(1, '#7A8896');
      } else {
        sph.addColorStop(0, '#FAFBFC');
        sph.addColorStop(0.55, '#EEF2F5');
        sph.addColorStop(0.92, '#D3DBE2');
        sph.addColorStop(1, '#B0BED1');
      }
      ctx.fillStyle = sph;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill();

      // Whisper-soft terminator
      const term = ctx.createRadialGradient(cx + R * 0.55, cy + R * 0.30, R * 0.10, cx, cy, R);
      term.addColorStop(0, 'rgba(40,44,52,0.09)');
      term.addColorStop(0.6, 'rgba(40,44,52,0.03)');
      term.addColorStop(1, 'rgba(40,44,52,0)');
      ctx.fillStyle = term;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill();

      // Subtle rim
      ctx.strokeStyle = 'rgba(40,44,52,0.16)';
      ctx.lineWidth = 0.8;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();

      // Graticule — meridians & parallels (whisper)
      ctx.strokeStyle = 'rgba(40,44,52,0.06)';
      ctx.lineWidth = 0.6;
      // parallels (constant lat lines)
      for (let lat = -60; lat <= 60; lat += 30) {
        ctx.beginPath();
        let started = false;
        for (let lon = -180; lon <= 180; lon += 6) {
          const v = rotX(rotY(lonLatToVec(lon, lat), st.rotY), st.rotX);
          if (v.z > 0) {
            const sx = cx + v.x * R, sy = cy - v.y * R;
            if (!started) { ctx.moveTo(sx, sy); started = true; }
            else ctx.lineTo(sx, sy);
          } else {
            started = false;
          }
        }
        ctx.stroke();
      }
      // meridians (constant lon lines)
      for (let lon = -150; lon <= 180; lon += 30) {
        ctx.beginPath();
        let started = false;
        for (let lat = -85; lat <= 85; lat += 4) {
          const v = rotX(rotY(lonLatToVec(lon, lat), st.rotY), st.rotX);
          if (v.z > 0) {
            const sx = cx + v.x * R, sy = cy - v.y * R;
            if (!started) { ctx.moveTo(sx, sy); started = true; }
            else ctx.lineTo(sx, sy);
          } else {
            started = false;
          }
        }
        ctx.stroke();
      }

      // Country polygons — filled + stroked so adjacent borders are visible.
      // When a polygon edge crosses the sphere's silhouette (z=0), the gap is
      // closed along the sphere's edge as an arc — never a straight chord.
      ctx.fillStyle = 'rgba(40,44,52,0.78)';
      ctx.strokeStyle = 'rgba(251,246,238,0.32)';
      ctx.lineWidth = 0.6;
      const arcOnRim = (from, to) => {
        let d = to - from;
        while (d > Math.PI) d -= 2 * Math.PI;
        while (d < -Math.PI) d += 2 * Math.PI;
        // Pick the shorter direction around the rim
        ctx.arc(cx, cy, R, from, to, d < 0);
      };
      for (const polyVecs of CONTINENT_POLY_VECS) {
        const n = polyVecs.length;
        if (n < 3) continue;
        // Project all vertices
        const proj = new Array(n);
        let visCount = 0;
        for (let i = 0; i < n; i++) {
          const v0 = polyVecs[i];
          const v = rotX(rotY(v0, st.rotY), st.rotX);
          proj[i] = { sx: cx + v.x * R, sy: cy - v.y * R, z: v.z };
          if (v.z >= 0) visCount++;
        }
        if (visCount === 0) continue;

        ctx.beginPath();
        if (visCount === n) {
          // Fully visible — straightforward fill + border stroke
          ctx.moveTo(proj[0].sx, proj[0].sy);
          for (let i = 1; i < n; i++) ctx.lineTo(proj[i].sx, proj[i].sy);
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
          continue;
        }

        // Partial — walk edges, replacing each "outside" run with an arc along the rim.
        let started = false;
        let pendingExitAngle = null;
        let firstEntryAngle = null;

        for (let i = 0; i < n; i++) {
          const a = proj[i];
          const b = proj[(i + 1) % n];
          const av = a.z >= 0;
          const bv = b.z >= 0;

          if (av) {
            if (!started) {
              ctx.moveTo(a.sx, a.sy);
              started = true;
            } else if (pendingExitAngle !== null) {
              const angle = Math.atan2(a.sy - cy, a.sx - cx);
              arcOnRim(pendingExitAngle, angle);
              pendingExitAngle = null;
              ctx.lineTo(a.sx, a.sy);
            } else {
              ctx.lineTo(a.sx, a.sy);
            }
          }
          if (av && !bv) {
            // Exit — line to the z=0 crossing, remember the rim angle
            const t = a.z / (a.z - b.z);
            const ex = a.sx + (b.sx - a.sx) * t;
            const ey = a.sy + (b.sy - a.sy) * t;
            ctx.lineTo(ex, ey);
            pendingExitAngle = Math.atan2(ey - cy, ex - cx);
          } else if (!av && bv) {
            // Entry — arc from previous exit to this entry, then continue inside
            const t = a.z / (a.z - b.z);
            const ex = a.sx + (b.sx - a.sx) * t;
            const ey = a.sy + (b.sy - a.sy) * t;
            const angle = Math.atan2(ey - cy, ex - cx);
            if (!started) {
              ctx.moveTo(ex, ey);
              started = true;
              firstEntryAngle = angle;
            } else if (pendingExitAngle !== null) {
              arcOnRim(pendingExitAngle, angle);
              pendingExitAngle = null;
            } else {
              ctx.lineTo(ex, ey);
              if (firstEntryAngle === null) firstEntryAngle = angle;
            }
          }
        }

        // Polygon ended with an unmatched exit — close by arcing back to the
        // first entry on the rim (only meaningful if the polygon started on the rim).
        if (pendingExitAngle !== null && firstEntryAngle !== null) {
          arcOnRim(pendingExitAngle, firstEntryAngle);
        }

        ctx.closePath();
        ctx.fill();
        ctx.stroke();
      }

      // Arc paths
      const projectedArcs = ARC_DATA.map(arc => {
        const sp = arc.pts.map(p => {
          const v = rotX(rotY(p, st.rotY), st.rotX);
          return { x: cx + v.x * R, y: cy - v.y * R, z: v.z };
        });
        return { ...arc, sp };
      });
      for (const arc of projectedArcs) {
        // Per-segment stroking so each piece can fade with depth
        for (let i = 0; i < arc.sp.length - 1; i++) {
          const p = arc.sp[i], q = arc.sp[i + 1];
          if (p.z < -0.02 && q.z < -0.02) continue;
          const depth = Math.max(0, Math.min(1, ((p.z + q.z) / 2 + 0.05) / 1.0));
          const a = 0.16 + depth * 0.40; // a touch bolder so the mesh reads clearly
          ctx.beginPath();
          ctx.moveTo(p.x, p.y);
          ctx.lineTo(q.x, q.y);
          ctx.strokeStyle = `rgba(81,177,254,${a.toFixed(3)})`;
          ctx.lineWidth = 0.9;
          ctx.stroke();
        }
      }

      // Animated packets along arcs — comet head + fading tail
      const packetAt = (arc, tt) => {
        const sp = arc && arc.sp;
        if (!sp || sp.length < 2) return null;
        const t = (isFinite(tt) ? Math.max(0, Math.min(1, tt)) : 0);
        const f = t * (sp.length - 1);
        const i0 = Math.max(0, Math.min(sp.length - 2, Math.floor(f)));
        const i1 = i0 + 1;
        const k = f - i0;
        const p0 = sp[i0], p1 = sp[i1];
        if (!p0 || !p1) return null;
        return { x: p0.x + (p1.x - p0.x) * k, y: p0.y + (p1.y - p0.y) * k, z: p0.z + (p1.z - p0.z) * k };
      };
      const dur = 3.2;
      const safeMod = (a, m) => { const r = a % m; return r < 0 ? r + m : r; }; // keep tt in [0, dur)
      for (let i = 0; i < projectedArcs.length; i++) {
        // A packet rides every arc now (was every other one) so the mesh feels
        // busier and the movement is easy to read across all continents.
        const arc = projectedArcs[i];
        const tt = safeMod(st.time + i * 0.41, dur) / dur;
        const head = packetAt(arc, tt);
        if (!head || head.z < -0.05) continue;
        const visibility = Math.max(0, Math.min(1, (head.z + 0.05) / 0.35));
        if (visibility <= 0) continue;

        // Trailing tail — fading samples behind the head
        const tailLen = 0.06;
        for (let s = 1; s <= 5; s++) {
          const tprev = tt - tailLen * (s / 5);
          if (tprev <= 0) break;
          const pt = packetAt(arc, tprev);
          if (!pt || pt.z < -0.05) continue;
          const tailA = (1 - s / 5) * 0.34 * visibility;
          ctx.beginPath();
          ctx.arc(pt.x, pt.y, 1.6 - s * 0.16, 0, Math.PI * 2);
          ctx.fillStyle = `rgba(81,177,254,${tailA.toFixed(3)})`;
          ctx.fill();
        }

        // Packet head — brighter and a little larger so it clearly moves.
        const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 11);
        g.addColorStop(0, `rgba(81,177,254,${(0.72 * visibility).toFixed(3)})`);
        g.addColorStop(0.4, `rgba(81,177,254,${(0.30 * visibility).toFixed(3)})`);
        g.addColorStop(1, 'rgba(81,177,254,0)');
        ctx.fillStyle = g;
        ctx.beginPath(); ctx.arc(head.x, head.y, 11, 0, Math.PI * 2); ctx.fill();
        // White core
        ctx.fillStyle = `rgba(255,255,255,${(0.92 * visibility).toFixed(3)})`;
        ctx.beginPath(); ctx.arc(head.x, head.y, 2.1, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = `rgba(81,177,254,${(0.85 * visibility).toFixed(3)})`;
        ctx.beginPath(); ctx.arc(head.x, head.y, 1.3, 0, Math.PI * 2); ctx.fill();
      }

      // Nodes — every marker is stamped with the Optimum loop mark and is
      // interactive (hover/click).
      const projNodes = NODE_VECS.map((v) => {
        const p = rotX(rotY(v.vec, st.rotY), st.rotX);
        return { ...v, sx: cx + p.x * R, sy: cy - p.y * R, z: p.z };
      }).sort((a, b) => a.z - b.z);

      // Stamp a white disc + navy Optimum mark so it reads over both the dark
      // country fills and the light ocean/sphere.
      const markAspect = 144 / 150;
      const stampMark = (x, y, r, alpha, active) => {
        ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
        ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
        ctx.fillStyle = '#F4F6F8'; ctx.fill();
        ctx.lineWidth = active ? 1.1 : 0.7;
        ctx.strokeStyle = active ? '#51B1FE' : 'rgba(81,177,254,0.55)';
        ctx.stroke();
        if (OP_MARK_READY) {
          const w = r * 1.5, h = w * markAspect;
          ctx.drawImage(OP_MARK_IMG, x - w / 2, y - h / 2, w, h);
        } else {
          ctx.beginPath(); ctx.arc(x, y, r * 0.5, 0, Math.PI * 2);
          ctx.fillStyle = '#282C34'; ctx.fill();
        }
        ctx.globalAlpha = 1;
      };

      const hits = [];
      let hoverProj = null;
      for (const n of projNodes) {
        if (n.z <= -0.05) continue;
        const isActive = n.id === activeId;
        const isHover = n.id === hovered;
        const limb = Math.max(0, Math.min(1, (n.z + 0.05) / 1.05));
        const baseAlpha = 0.30 + limb * 0.70;

        // Always-alive heartbeat — every node breathes a little.
        const phase = st.time * 1.8 + n.lat * 0.13 + n.lon * 0.07;
        const pulse = (Math.sin(phase) + 1) * 0.5;

        // Active node: two expanding ripples + bright halo behind the mark
        if (isActive) {
          for (let k = 0; k < 2; k++) {
            const tt = ((st.time * 0.85 + k * 0.5) % 1);
            const rr = 7 + tt * 18;
            const aa = (1 - tt) * 0.34 * baseAlpha;
            ctx.beginPath();
            ctx.arc(n.sx, n.sy, rr, 0, Math.PI * 2);
            ctx.strokeStyle = `rgba(81,177,254,${aa.toFixed(3)})`;
            ctx.lineWidth = 1.0;
            ctx.stroke();
          }
          const halo = ctx.createRadialGradient(n.sx, n.sy, 0, n.sx, n.sy, 22);
          halo.addColorStop(0, `rgba(81,177,254,${(0.42 * baseAlpha).toFixed(3)})`);
          halo.addColorStop(0.6, `rgba(81,177,254,${(0.14 * baseAlpha).toFixed(3)})`);
          halo.addColorStop(1, 'rgba(81,177,254,0)');
          ctx.fillStyle = halo;
          ctx.beginPath(); ctx.arc(n.sx, n.sy, 22, 0, Math.PI * 2); ctx.fill();
        } else {
          // Idle nodes: subtle pulse ring + softer glow
          const ringR = 5 + pulse * 3.4;
          const ringA = 0.22 * baseAlpha * (1 - pulse * 0.55);
          ctx.beginPath();
          ctx.arc(n.sx, n.sy, ringR + limb * 3, 0, Math.PI * 2);
          ctx.strokeStyle = `rgba(81,177,254,${ringA.toFixed(3)})`;
          ctx.lineWidth = 0.75;
          ctx.stroke();

          const glowR = isHover ? 16 : 11;
          const glow = ctx.createRadialGradient(n.sx, n.sy, 0, n.sx, n.sy, glowR);
          glow.addColorStop(0, `rgba(81,177,254,${((isHover ? 0.4 : 0.22) * baseAlpha).toFixed(3)})`);
          glow.addColorStop(1, 'rgba(81,177,254,0)');
          ctx.fillStyle = glow;
          ctx.beginPath(); ctx.arc(n.sx, n.sy, glowR, 0, Math.PI * 2); ctx.fill();
        }

        // The Optimum mark itself — sized by depth, a touch bigger when focused.
        const baseR = 6.2 * (0.66 + 0.42 * limb);
        const r = baseR * (isActive ? 1.28 : isHover ? 1.14 : 1);
        stampMark(n.sx, n.sy, r, baseAlpha, isActive || isHover);

        hits.push({ id: n.id, sx: n.sx, sy: n.sy, r: 14 });
        if (isHover) hoverProj = n;
      }
      hitsRef.current = hits;

      // Update hover-label DOM position — only the hovered node is labelled,
      // so the globe stays clean by default and the user discovers names by
      // mousing over the dots.
      if (hoverProj && hoverProj.z > -0.05) {
        setLabelPos(prev => {
          const next = { id: hoverProj.id, x: hoverProj.sx, y: hoverProj.sy, vis: hoverProj.z };
          if (!prev || prev.id !== next.id || Math.abs(prev.x - next.x) > 0.5 || Math.abs(prev.y - next.y) > 0.5 || Math.abs(prev.vis - next.vis) > 0.02) return next;
          return prev;
        });
      } else if (labelPos) {
        setLabelPos(null);
      }
    };
    raf = requestAnimationFrame(draw);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); if (io) io.disconnect(); };
  }, []);

  const dragRef = React.useRef({ active: false, id: null, lx: 0, ly: 0, lt: 0, ox: 0, oy: 0, vx: 0, vy: 0, moved: 0 });

  const pickAt = (mx, my) => {
    let best = null, bd = Infinity;
    for (const h of hitsRef.current) {
      const d = Math.hypot(h.sx - mx, h.sy - my);
      if (d < h.r && d < bd) { bd = d; best = h.id; }
    }
    return best;
  };

  const handleMove = (e) => {
    const rect = canvasRef.current.getBoundingClientRect();
    const mx = e.clientX - rect.left, my = e.clientY - rect.top;
    const drag = dragRef.current;
    if (drag.active) {
      // Ignore a second finger: its coordinates would be differenced against
      // the first finger's last position and snap the globe.
      if (drag.id !== null && e.pointerId !== drag.id) return;
      const dx = e.clientX - drag.lx;
      const dy = e.clientY - drag.ly;
      const now = performance.now();
      const dt = Math.max(1, now - drag.lt);
      // ~ 0.005 rad / px feels natural at this size
      stateRef.current.rotY += dx * 0.005;
      stateRef.current.rotX = Math.max(-1.0, Math.min(1.0, stateRef.current.rotX - dy * 0.005));
      drag.vx = dx * 0.005 / dt;     // rad / ms
      drag.vy = -dy * 0.005 / dt;
      drag.lx = e.clientX; drag.ly = e.clientY; drag.lt = now;
      drag.moved += Math.hypot(dx, dy);
      canvasRef.current.style.cursor = 'grabbing';
      return;
    }
    const best = pickAt(mx, my);
    if (best !== hoveredRef.current) setHovered(best);
    canvasRef.current.style.cursor = best ? 'pointer' : 'grab';
  };

  const handleDown = (e) => {
    const drag = dragRef.current;
    // First finger down owns the drag until it lifts. Without this a second
    // finger overwrites lx/ly, so the next move by the FIRST finger is
    // differenced against the SECOND finger's position and the globe snaps.
    // This is also what gives drag.id a value -- every guard in handleMove /
    // handleUp / handleCancel is keyed on it.
    if (drag.active && drag.id !== null && e.pointerId !== drag.id) return;
    drag.active = true; drag.id = e.pointerId; drag.moved = 0;
    drag.lx = e.clientX; drag.ly = e.clientY; drag.lt = performance.now();
    drag.ox = e.clientX; drag.oy = e.clientY;   // press origin
    drag.vx = 0; drag.vy = 0;
    drag.touch = e.pointerType === 'touch';
    stateRef.current.paused = true;
    stateRef.current.spinVY = 0; stateRef.current.spinVX = 0;
    // Don't capture touch pointers: the browser must stay free to claim a
    // vertical swipe as a page scroll (touch-action: pan-y). Mouse/pen still
    // capture so a drag that leaves the canvas keeps rotating.
    if (!drag.touch) canvasRef.current.setPointerCapture && canvasRef.current.setPointerCapture(e.pointerId);
    canvasRef.current.style.cursor = 'grabbing';
  };

  const handleUp = (e) => {
    const drag = dragRef.current;
    if (!drag.active || (drag.id !== null && e.pointerId !== drag.id)) return;
    drag.active = false; drag.id = null;
    stateRef.current.paused = false;

    if (drag.moved >= 4) {
      // Real drag — carry brief momentum then settle. On mouse/pen keep
      // auto-rotation latched off so the user can click a node afterwards
      // without the globe drifting; on touch let ambient spin resume so the
      // globe never ends up frozen after a swipe.
      if (!drag.touch) stateRef.current.userControl = true;
      stateRef.current.spinVY = drag.vx * 16;
      stateRef.current.spinVX = drag.vy * 16;
    } else {
      // Treated as a click — pick the node under the pointer.
      const rect = canvasRef.current.getBoundingClientRect();
      const best = pickAt(e.clientX - rect.left, e.clientY - rect.top);
      if (best && onPick) onPick(best);
    }

    try { canvasRef.current.releasePointerCapture && canvasRef.current.releasePointerCapture(e.pointerId); } catch (_) {}
    canvasRef.current.style.cursor = 'grab';
  };

  // The browser fires pointercancel when it takes over a gesture — e.g. a
  // vertical swipe becoming a page scroll. End the drag WITHOUT treating it as
  // a tap (so scrolling never accidentally selects a node) and let ambient
  // rotation resume.
  const handleCancel = (e) => {
    const drag = dragRef.current;
    if (drag.active && drag.id !== null && e.pointerId !== drag.id) return;
    drag.active = false; drag.id = null;
    stateRef.current.paused = false;
    stateRef.current.userControl = false;
    try { canvasRef.current && canvasRef.current.releasePointerCapture && canvasRef.current.releasePointerCapture(e.pointerId); } catch (_) {}
    if (canvasRef.current) canvasRef.current.style.cursor = 'grab';
  };

  const handleLeave = () => {
    setHovered(null);
    if (canvasRef.current) canvasRef.current.style.cursor = 'grab';
  };

  const hoveredN = NODE_LOCATIONS.find(n => n.id === hovered);

  return (
    <div className="op-globe-wrap">
      <canvas
        ref={canvasRef}
        onPointerMove={handleMove}
        onPointerDown={handleDown}
        onPointerUp={handleUp}
        onPointerCancel={handleCancel}
        onMouseLeave={handleLeave}
        className="op-globe-canvas"
        style={{ cursor: 'grab', touchAction: 'pan-y pinch-zoom' }}
      />
      {hoveredN && labelPos && (
        <div className="op-globe-label" style={{
          left: labelPos.x, top: labelPos.y - 22,
          opacity: Math.max(0.25, Math.min(1, labelPos.vis * 2)),
        }}>
          <span className="dot" />
          {hoveredN.city} · {hoveredN.count} node{hoveredN.count === 1 ? '' : 's'}
        </div>
      )}
    </div>
  );
}

// Inject globe-specific styles once
if (typeof document !== 'undefined' && !document.getElementById('op-globe-styles')) {
  const s = document.createElement('style');
  s.id = 'op-globe-styles';
  s.textContent = `
    .op-globe-label {
      position: absolute;
      transform: translate(-50%, -100%);
      font-family: 'Geist Mono', ui-monospace, monospace;
      font-size: 11px;
      letter-spacing: 0.02em;
      color: #282C34;
      background: rgba(255,255,255,0.92);
      border: 1px solid rgba(40,44,52,0.18);
      backdrop-filter: blur(8px);
      padding: 4px 9px 4px 8px;
      border-radius: 999px;
      box-shadow: 0 6px 18px rgba(40,44,52,0.10);
      white-space: nowrap;
      pointer-events: none;
      display: inline-flex; align-items: center; gap: 6px;
      transition: opacity .25s ease;
      z-index: 3;
    }
    .op-globe-label .dot {
      width: 6px; height: 6px; border-radius: 50%;
      background: #51B1FE;
      box-shadow: 0 0 0 2px rgba(81,177,254,0.22), 0 0 8px rgba(81,177,254,0.5);
    }
    .op-globe-canvas {
      position: absolute;
      inset: 0;
      z-index: 1;
      width: 100%;
      height: 100%;
      display: block;
    }
    .op-globe-wrap {
      position: absolute;
      inset: 0;
      z-index: 1;
    }
    /* Also disable globe dragging on a phone held in landscape: the canvas
       then covers most of a ~400px-tall screen, and because it swallows
       vertical drags there is no room left to scroll past it. Needs
       hover:none + pointer:coarse + a short viewport, so no desktop and no
       iPad (768-1024px tall in landscape) can match it. */
    @media (max-width: 480px),
           (hover: none) and (pointer: coarse) and (max-height: 500px) {
      /* On phones the globe is ambient-only: make the canvas transparent to
         touch so a swipe ALWAYS scrolls the page, no matter where it starts.
         The touch target falls through to .op-globe-wrap (touch-action: auto),
         which lets the document scroll natively. The SEARCH and EXPAND MAP
         controls are separate elements above the canvas, so they still work.
         Ambient rotation is animation-driven and unaffected by pointer-events. */
      .op-globe-canvas { pointer-events: none !important; }
    }
  `;
  document.head.appendChild(s);
}

Object.assign(window, { GlobeViz, VALIDATORS });
