// Vantage — Build 5: The Globe. The literal vantage point.
// A spinning orthographic Earth rendered as hand-rolled SVG (no map library):
// land rings from a committed Natural Earth 110m extract (window.VG_WORLD),
// hotspot layers from /vantage/api/globe — natural events + quakes (weather),
// news-derived unrest, and market chatter. Every layer degrades to a labeled
// dark state. Hotspots expand into a panel of source articles (and a hover
// preview); tagged tickers bridge into the research dossier. Scroll to zoom.
// Three shadings: vanta (black earth), natural (colored vector earth), and live
// (NASA GIBS satellite imagery texture-mapped onto the sphere via canvas).

const VG_GLOBE_SHADES = {
  vanta: {
    ocean: '#0a0a0e', oceanStroke: 'rgba(244,242,247,0.18)',
    land: '#121118', landStroke: 'rgba(244,242,247,0.35)',
    border: 'rgba(244,242,247,0.22)', grat: 'rgba(244,242,247,0.05)',
    // Per-layer colors even on black, so a filter's dots are identifiable and
    // toggling one is visibly meaningful (weather amber, unrest red, news gold).
    spot: { weather: '#fb923c', unrest: '#f87171', chatter: '#fde047' },
    halo: 'rgba(248,113,113,0.22)',
  },
  natural: {
    ocean: '#16324f', oceanStroke: 'rgba(244,242,247,0.15)',
    land: '#3a5f4b', landStroke: 'rgba(10,20,15,0.6)',
    border: 'rgba(10,20,15,0.5)', grat: 'rgba(244,242,247,0.07)',
    spot: { weather: '#fb923c', unrest: '#f87171', chatter: '#fde047' },
    halo: 'rgba(251,146,60,0.25)',
  },
  // Live satellite imagery is the fill; the vector layer only draws borders +
  // graticule on top for orientation. Falls back to these colors while the
  // texture loads or if it's unreachable.
  live: {
    ocean: '#060a12', oceanStroke: 'rgba(244,242,247,0.14)',
    land: '#101a22', landStroke: 'rgba(255,255,255,0.32)',
    border: 'rgba(255,255,255,0.30)', grat: 'rgba(255,255,255,0.06)',
    spot: { weather: '#fb923c', unrest: '#f87171', chatter: '#fde047' },
    halo: 'rgba(251,146,60,0.25)',
  },
};

const VG_LAYER_COLOR = { weather: '#fb923c', unrest: '#f87171', chatter: '#fde047' };

// Inverse-orthographic texture remap: paint each disk pixel by sampling a
// PREPARED equirectangular source ({data,w,h} ImageData fields — composited
// once when the imagery loads, so the per-frame cost is only this loop) at the
// lon/lat that projects there. outR picks the working resolution: small while
// dragging so the imagery tracks the rotation in real time, supersampled when
// parked so the browser downscale smooths it.
function vgRemapEquirect(src, lam0, phi0, outR) {
  const D = 2 * outR;
  const { data: sd, w: sw, h: sh } = src;
  const cvs = document.createElement('canvas');
  cvs.width = D; cvs.height = D;
  const ctx = cvs.getContext('2d');
  const out = ctx.createImageData(D, D);
  const o = out.data;
  const p0 = phi0 * VG_D2R, l0 = lam0 * VG_D2R;
  const sinp0 = Math.sin(p0), cosp0 = Math.cos(p0);
  for (let oy = 0; oy < D; oy++) {
    const ym = outR - oy;                    // math-up (our projection negates screen-y)
    for (let ox = 0; ox < D; ox++) {
      const x = ox - outR;
      const rho = Math.hypot(x, ym);
      const di = (oy * D + ox) * 4;
      if (rho > outR) { o[di + 3] = 0; continue; }          // outside the disk → transparent
      const c = Math.asin(Math.min(1, rho / outR));
      const sinc = Math.sin(c), cosc = Math.cos(c);
      const lat = rho === 0 ? p0 : Math.asin(cosc * sinp0 + (ym * sinc * cosp0) / rho);
      const lon = l0 + Math.atan2(x * sinc, rho * cosc * cosp0 - ym * sinc * sinp0);
      let sx = Math.floor((((lon / VG_D2R + 180) % 360 + 360) % 360) / 360 * sw);
      let sy = Math.floor((90 - lat / VG_D2R) / 180 * sh);
      if (sx >= sw) sx = sw - 1; if (sx < 0) sx = 0;
      if (sy >= sh) sy = sh - 1; if (sy < 0) sy = 0;
      const si = (sy * sw + sx) * 4;
      o[di] = sd[si]; o[di + 1] = sd[si + 1]; o[di + 2] = sd[si + 2]; o[di + 3] = 255;
    }
  }
  ctx.putImageData(out, 0, 0);
  return cvs.toDataURL('image/png');
}

// Split a hotspot label into a skim-able hierarchy: name first, then the
// region read broadest-first. "Hoover Fire, Tulare, California" → name "Hoover
// Fire", region "California · Tulare". Quake labels ("M6.1 — off Honshu,
// Japan") keep the magnitude in the name.
function vgEventParts(h) {
  const label = String(h?.label || '');
  if (h?.kind === 'quake') {
    const [mag, place = ''] = label.split('—').map(s => s.trim());
    const parts = place.split(',').map(s => s.trim()).filter(Boolean);
    return { name: [mag, parts[0]].filter(Boolean).join(' · '), region: parts.slice(1).reverse().join(' · ') };
  }
  const parts = label.split(',').map(s => s.trim()).filter(Boolean);
  return { name: parts[0] || label, region: parts.slice(1).reverse().join(' · ') };
}

// GEOGRAPHIC greedy clustering — mirror of clusterPointsGeo in
// src/vantage-market.js (tested there); keep the two in sync. On the sphere,
// not the screen: membership never depends on rotation, so cluster counts hold
// steady while the globe turns and a cluster leaves the limb as one unit.
function vgClusterGeo(points, angRadiusDeg) {
  const chord = 2 * Math.sin(Math.min(180, Math.max(0, angRadiusDeg)) * VG_D2R / 2);
  const chord2 = chord * chord;
  const vec = p => {
    const la = p.lat * VG_D2R, lo = p.lon * VG_D2R;
    return [Math.cos(la) * Math.cos(lo), Math.cos(la) * Math.sin(lo), Math.sin(la)];
  };
  const clusters = [];
  for (const p of points || []) {
    if (!Number.isFinite(p?.lon) || !Number.isFinite(p?.lat)) continue;
    const [x, y, z] = vec(p);
    let hit = null;
    for (const c of clusters) {
      const n = Math.hypot(c.vx, c.vy, c.vz) || 1e-9;
      const dx = x - c.vx / n, dy = y - c.vy / n, dz = z - c.vz / n;
      if (dx * dx + dy * dy + dz * dz <= chord2) { hit = c; break; }
    }
    if (hit) { hit.vx += x; hit.vy += y; hit.vz += z; hit.members.push(p); }
    else clusters.push({ vx: x, vy: y, vz: z, members: [p] });
  }
  return clusters.map(c => {
    const n = Math.hypot(c.vx, c.vy, c.vz) || 1e-9;
    return {
      lon: Math.atan2(c.vy / n, c.vx / n) / VG_D2R,
      lat: Math.asin(Math.max(-1, Math.min(1, c.vz / n))) / VG_D2R,
      members: c.members, count: c.members.length,
    };
  });
}

// Human titles for EONET/USGS source ids — the raw links (IRWIN especially)
// land on machine-readable records that explain nothing; say what each is,
// and the panel adds a news search so every event has a human-readable path.
const VG_SOURCE_TITLES = {
  IRWIN: 'official incident record (IRWIN)', InciWeb: 'InciWeb incident page',
  USGS: 'USGS event page', GDACS: 'GDACS disaster report', EO: 'NASA Earth Observatory',
  PDC: 'Pacific Disaster Center', SIVolcano: 'Smithsonian volcano record', NOAA_NHC: 'NOAA hurricane center',
};
const vgSourceTitle = t => VG_SOURCE_TITLES[t] || t || 'source';
// News-search query worth clicking: the event's name, its broadest region, and
// the kind spelled the way headlines spell it — skipping words the name
// already carries ("HOOVER FIRE" doesn't need "fire" appended twice).
const VG_KIND_WORDS = {
  fire: 'wildfire', quake: 'earthquake', storm: 'storm', flood: 'flooding',
  volcano: 'volcano eruption', drought: 'drought', dust: 'dust storm',
  landslide: 'landslide', heat: 'heat wave', snow: 'snow storm', ice: 'sea ice',
  unrest: 'unrest', chatter: '',
};
const vgNewsSearch = h => {
  const ep = vgEventParts(h);
  const parts = [ep.name, ep.region.split(' · ')[0]];
  const kw = VG_KIND_WORDS[h?.kind] ?? h?.kind ?? '';
  if (kw && !parts.join(' ').toLowerCase().includes(kw.split(' ')[0].toLowerCase())) parts.push(kw);
  return `https://news.google.com/search?q=${encodeURIComponent(parts.filter(Boolean).join(' '))}`;
};

const VG_GLOBE_LAYERS = [
  ['weather', 'weather · fires, storms, floods, quakes'],
  ['unrest', 'events · conflict & unrest'],
  ['chatter', 'news · market topics rising'],
];

const VG_D2R = Math.PI / 180;

// Orthographic forward projection around center (lam0, phi0), radius R.
// Hidden-hemisphere vertices are pushed radially to the limb so land rings
// stay closed and fill cleanly — a stylized globe, not a cartographic one.
function vgProject(lon, lat, lam0, phi0, R) {
  const lam = lon * VG_D2R, phi = lat * VG_D2R;
  const l0 = lam0 * VG_D2R, p0 = phi0 * VG_D2R;
  const dl = lam - l0;
  const cosc = Math.sin(p0) * Math.sin(phi) + Math.cos(p0) * Math.cos(phi) * Math.cos(dl);
  let x = Math.cos(phi) * Math.sin(dl);
  let y = Math.cos(p0) * Math.sin(phi) - Math.sin(p0) * Math.cos(phi) * Math.cos(dl);
  if (cosc < 0) {
    const n = Math.hypot(x, y) || 1e-9;
    x /= n; y /= n;
  }
  // cosc is the cosine of the angular distance from the view center: 1 dead
  // ahead, 0 at the limb, <0 on the hidden hemisphere. Hotspots use it to
  // foreshorten and fade toward the rim so they sit on the sphere.
  return { x: x * R, y: -y * R, visible: cosc >= 0, cosc };
}

function vgRingPath(ring, lam0, phi0, R) {
  let d = '', anyVisible = false;
  for (let i = 0; i < ring.length; i++) {
    const p = vgProject(ring[i][0], ring[i][1], lam0, phi0, R);
    if (p.visible) anyVisible = true;
    d += (i ? 'L' : 'M') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
  }
  return anyVisible ? d + 'Z' : null;
}

function vgLinePath(line, lam0, phi0, R) {
  let d = '', pen = false, anyVisible = false;
  for (const [lon, lat] of line) {
    const p = vgProject(lon, lat, lam0, phi0, R);
    if (!p.visible) { pen = false; continue; }
    anyVisible = true;
    d += (pen ? 'L' : 'M') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
    pen = true;
  }
  return anyVisible ? d : null;
}

function vgGraticule(lam0, phi0, R) {
  const lines = [];
  for (let lon = -180; lon < 180; lon += 30) {
    const line = [];
    for (let lat = -85; lat <= 85; lat += 5) line.push([lon, lat]);
    lines.push(line);
  }
  for (let lat = -60; lat <= 60; lat += 30) {
    const line = [];
    for (let lon = -180; lon <= 180; lon += 5) line.push([lon, lat]);
    lines.push(line);
  }
  return lines.map(l => vgLinePath(l, lam0, phi0, R)).filter(Boolean);
}

// The reusable sphere: projection, spin, drag, hotspots. The full globe tab
// wraps it with layer chips and the hotspot panel; the pulse tab embeds a
// medium one as a live monitor. One renderer, so the two can never drift.
function VgGlobeSvg({ data, layers, shade, spin, frozen, onPick, onUserSpin, onStats, zoomable, bookOnly, focus }) {
  const [rot, setRot] = React.useState({ lam: -30, phi: 18 });
  const [dragging, setDragging] = React.useState(false);
  const [zoom, setZoom] = React.useState(1);
  const [hover, setHover] = React.useState(null);
  const [texUrl, setTexUrl] = React.useState(null);        // remapped live-imagery dataURL
  const [texState, setTexState] = React.useState('idle');  // idle | loading | ready | error
  const [precipOk, setPrecipOk] = React.useState(false);   // did the precipitation overlay arrive?
  const [texV, setTexV] = React.useState(0);               // bumped when the composited source changes
  const dragRef = React.useRef(null);
  const texSrcRef = React.useRef(null);                    // { data, w, h } — composited base+precip ImageData
  const baseImgRef = React.useRef(null);
  const precipImgRef = React.useRef(null);
  const svgRef = React.useRef(null);
  const live = shade === 'live';
  const R = 296, CX = 320, CY = 320;
  const { lam, phi } = rot;

  // Only animate while actually watchable: on-screen AND the browser tab
  // visible. Off-screen or hidden, the 20fps setRot loop was re-rendering the
  // whole SVG for nobody — the globe's quiet CPU leak.
  const [onScreen, setOnScreen] = React.useState(true);
  const [pageVisible, setPageVisible] = React.useState(!document.hidden);
  React.useEffect(() => {
    const el = svgRef.current;
    let io = null;
    if (el && typeof IntersectionObserver === 'function') {
      io = new IntersectionObserver(es => setOnScreen(es[0] ? es[0].isIntersecting : true), { threshold: 0.05 });
      io.observe(el);
    }
    const onVis = () => setPageVisible(!document.hidden);
    document.addEventListener('visibilitychange', onVis);
    return () => { if (io) io.disconnect(); document.removeEventListener('visibilitychange', onVis); };
  }, []);

  // Slow spin; paused while dragging, inspecting, hovering, in live mode (the
  // per-frame texture remap is too heavy to spin), off-screen, or in a hidden
  // tab. Never for reduced-motion.
  React.useEffect(() => {
    if (!spin || frozen || dragging || hover || live || !onScreen || !pageVisible ||
        (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) return;
    const t = setInterval(() => setRot(r => ({ ...r, lam: r.lam + 0.35 })), 50);
    return () => clearInterval(t);
  }, [spin, frozen, dragging, hover, live, onScreen, pageVisible]);

  // Scroll-to-zoom (non-passive so we can stop the page from scrolling). Only
  // on the full globe tab — the pulse embed shouldn't hijack page scroll.
  React.useEffect(() => {
    const el = svgRef.current;
    if (!zoomable || !el) return;
    const onWheel = e => { e.preventDefault(); setHover(null); setZoom(z => Math.max(1, Math.min(5, z * (e.deltaY < 0 ? 1.12 : 0.893)))); };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [zoomable]);

  const S = VG_GLOBE_SHADES[live ? 'live' : shade] || VG_GLOBE_SHADES.vanta;
  const world = window.VG_WORLD || { rings: [], borders: [] };

  // Load the imagery when live mode turns on (same-origin proxy → no canvas
  // taint). Two parts: Blue Marble base (gap-free earth) + IMERG precipitation
  // (the live weather field, transparent). They're composited ONCE into an
  // ImageData source so each remap is just the projection loop. Base failing →
  // honest degrade to the vector globe; precip failing → base alone, labeled.
  React.useEffect(() => {
    if (!live) { setTexState('idle'); setTexUrl(null); texSrcRef.current = null; baseImgRef.current = null; precipImgRef.current = null; return; }
    let alive = true;
    setTexState('loading'); setPrecipOk(false);
    const compose = () => {
      if (!alive || !baseImgRef.current) return;
      try {
        const w = 2048, h = 1024;
        const c = document.createElement('canvas'); c.width = w; c.height = h;
        const x = c.getContext('2d');
        x.drawImage(baseImgRef.current, 0, 0, w, h);
        if (precipImgRef.current) x.drawImage(precipImgRef.current, 0, 0, w, h);
        texSrcRef.current = { data: x.getImageData(0, 0, w, h).data, w, h };
        setTexV(v => v + 1);
      } catch { /* leave the previous source in place */ }
    };
    const base = new Image();
    base.onload = () => { if (alive) { baseImgRef.current = base; setTexState('ready'); compose(); } };
    base.onerror = () => { if (alive) setTexState('error'); };
    base.src = '/vantage/api/earth-texture?layer=base';
    const pr = new Image();
    pr.onload = () => { if (alive) { precipImgRef.current = pr; setPrecipOk(true); compose(); } };
    pr.onerror = () => { /* precip is optional garnish on the base */ };
    pr.src = '/vantage/api/earth-texture?layer=precip';
    return () => { alive = false; };
  }, [live]);

  // Remap the composited source to the current rotation. During a drag it runs
  // small and fast so the imagery TRACKS the rotation under the pointer; parked,
  // it re-renders supersampled so the browser downscale smooths it.
  React.useEffect(() => {
    if (!live || !texSrcRef.current) return;
    const outR = dragging ? 140 : 592;
    const t = setTimeout(() => {
      try { setTexUrl(vgRemapEquirect(texSrcRef.current, lam, phi, outR)); } catch { setTexUrl(null); }
    }, dragging ? 45 : 160);
    return () => clearTimeout(t);
  }, [live, texV, lam, phi, dragging]);

  const showTexture = live && texState === 'ready' && texUrl;
  const landPaths = React.useMemo(() => world.rings.map(r => vgRingPath(r, lam, phi, R)).filter(Boolean), [lam, phi, world]);
  const borderPaths = React.useMemo(() => world.borders.map(l => vgLinePath(l, lam, phi, R)).filter(Boolean), [lam, phi, world]);
  const gratPaths = React.useMemo(() => vgGraticule(lam, phi, R), [lam, phi]);

  // Pool the active layers, then cluster GEOGRAPHICALLY (≈4.6°/zoom matches
  // the old on-screen merge distance at the sphere's center). Membership is a
  // function of geography alone, so a cluster's count never flickers as the
  // globe turns — the whole mark fades out at the limb as one unit.
  const pool = [];
  if (data) {
    for (const [key] of VG_GLOBE_LAYERS) {
      if (!layers[key] || !data[key]) continue;
      for (const h of data[key]) {
        if (bookOnly && !h.book?.length) continue;              // "my book" lens: only hotspots touching held/watched names
        pool.push(h);
      }
    }
  }
  const total = pool.length;
  let visible = 0;
  const clusters = [];
  for (const c of vgClusterGeo(pool, 4.6 / zoom)) {
    const p = vgProject(c.lon, c.lat, lam, phi, R);
    if (p.cosc <= 0) continue;                                  // whole cluster on the far side
    visible += c.count;
    if (c.count === 1) {
      const h = c.members[0];
      const base = h.layer === 'weather'
        ? Math.min(4.6, 1.7 + (h.mag ?? 2) * 0.4)
        : Math.min(5, 1.7 + Math.log2((h.count ?? 1) + 1) * 0.85);
      clusters.push({ single: h, members: c.members, count: 1, x: p.x, y: p.y, depth: p.cosc,
        size: base * (0.5 + 0.5 * Math.sqrt(p.cosc)), fade: Math.min(1, p.cosc / 0.2) });
    } else {
      const layerMode = (() => { const n = {}; let best = c.members[0].layer;
        for (const m of c.members) { n[m.layer] = (n[m.layer] || 0) + 1; if (n[m.layer] > (n[best] || 0)) best = m.layer; }
        return best; })();
      clusters.push({ ...c, x: p.x, y: p.y, depth: p.cosc, fade: Math.min(1, p.cosc / 0.2), layer: layerMode });
    }
  }
  clusters.sort((a, b) => a.depth - b.depth);
  const liveInfo = live ? `${texState}:${precipOk ? 1 : 0}` : '';
  React.useEffect(() => { onStats?.(visible, total, live ? { state: texState, precip: precipOk } : null); }, [visible, total, liveInfo]);

  // Click = SELECT, always. A cluster click opens its members as a list in the
  // side panel (zooming stays on scroll/double-click, and the panel offers a
  // "zoom into this area" button) — clicking never yanks the camera.
  const openCluster = cl => {
    setHover(null);
    onUserSpin?.(false);
    onPick?.({ clusterList: cl.members, count: cl.count, layer: cl.layer,
      label: `${cl.count} events in this area`,
      lat: Math.round(cl.lat * 100) / 100, lon: Math.round(cl.lon * 100) / 100 });
  };

  // External recenter+zoom request (the panel's "zoom into this area").
  React.useEffect(() => {
    if (!focus) return;
    setRot({ lam: focus.lon, phi: Math.max(-72, Math.min(72, focus.lat)) });
    if (focus.z) setZoom(z => Math.max(z, focus.z));
    onUserSpin?.(false);
  }, [focus?.at]);

  const onDown = e => { dragRef.current = { x: e.clientX, y: e.clientY, lam, phi }; setDragging(true); setHover(null); onUserSpin?.(false); };
  const onMove = e => {
    const d = dragRef.current;
    if (!d) return;
    const k = 0.35 / zoom;                                     // finer control when zoomed in
    setRot({ lam: d.lam - (e.clientX - d.x) * k, phi: Math.max(-72, Math.min(72, d.phi + (e.clientY - d.y) * k)) });
  };
  const onUp = () => { dragRef.current = null; setDragging(false); };
  const vb = `${CX - 320 / zoom} ${CY - 320 / zoom} ${640 / zoom} ${640 / zoom}`;

  // Hover preview card — skim-able hierarchy: NAME, then region · kind · meta,
  // then the lead article. Clusters get "N events in this area". Flipped to
  // stay on-sphere.
  const card = hover ? (() => {
    let lines;
    if (hover.clusterCount) {
      lines = [`${hover.clusterCount} events in this area`, hover.names.join(' · ').slice(0, 40), 'click for the list'];
      if (hover.book?.length) lines.splice(1, 0, `in your book: ${[...new Set(hover.book)].join(' · ')}`.slice(0, 42));
    } else {
      const ep = vgEventParts(hover);
      lines = [ep.name.slice(0, 34)];
      const meta = [ep.region, hover.kind, hover.count != null && hover.layer !== 'weather' ? `${hover.count} mentions · 2d`
        : hover.mag != null ? `magnitude ${hover.mag}` : null].filter(Boolean).join('  ·  ');
      if (meta) lines.push(meta.slice(0, 44));
      if (hover.book?.length) lines.push(`in your book: ${hover.book.map(b => b.sym).join(' · ')}`.slice(0, 42));
      const t = hover.links?.[0]?.title;
      if (t) lines.push('“' + t.slice(0, 38) + (t.length > 38 ? '…' : '') + '”');
    }
    const w = Math.min(250, 16 + Math.max(...lines.map(l => l.length)) * 6);
    const h = 12 + lines.length * 15;
    let cx = hover.x + 12, cy = hover.y - h - 12;
    if (cx + w > R) cx = hover.x - w - 12;
    if (cy < -R) cy = hover.y + 14;
    return { cx, cy, w, h, lines };
  })() : null;

  return (
    <svg ref={svgRef} viewBox={vb} onDoubleClick={() => setZoom(1)}
      style={{ width: '100%', height: 'auto', display: 'block', touchAction: 'none', cursor: dragging ? 'grabbing' : 'grab' }}
      onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={onUp}>
      <defs>
        <clipPath id="vg-sphere-clip"><circle r={R} /></clipPath>
        <radialGradient id="vg-limb" cx="0.38" cy="0.34" r="0.75">
          <stop offset="72%" stopColor="rgba(0,0,0,0)" />
          <stop offset="100%" stopColor="rgba(0,0,0,0.42)" />
        </radialGradient>
        {Object.entries(VG_LAYER_COLOR).map(([k, col]) => (
          <radialGradient key={k} id={`vg-cl-${k}`}>
            <stop offset="0%" stopColor={col} stopOpacity="0.5" />
            <stop offset="100%" stopColor={col} stopOpacity="0" />
          </radialGradient>
        ))}
      </defs>
      <g transform={`translate(${CX},${CY})`}>
        <circle r={R} fill={S.ocean} stroke={S.oceanStroke} strokeWidth="1" />
        {showTexture && <image href={texUrl} x={-R} y={-R} width={2 * R} height={2 * R} clipPath="url(#vg-sphere-clip)" preserveAspectRatio="none" pointerEvents="none" />}
        {!live && gratPaths.map((d, i) => <path key={'g' + i} d={d} fill="none" stroke={S.grat} strokeWidth="0.6" />)}
        {!showTexture && landPaths.map((d, i) => <path key={'l' + i} d={d} fill={S.land} stroke={S.landStroke} strokeWidth="0.7" />)}
        {borderPaths.map((d, i) => <path key={'b' + i} d={d} fill="none" stroke={S.border} strokeWidth={live ? 0.7 : 0.5} />)}
        {clusters.map((cl, i) => {
          // Marks shrink gently as you zoom so magnification adds PRECISION —
          // dots resolve apart instead of scaling up into the same pile. Every
          // mark carries an oversized transparent hit circle: selecting should
          // never require pixel-hunting a 3px dot.
          const zs = Math.pow(zoom, 0.45);
          if (cl.single) {
            const h = cl.single;
            return (
              <g key={'s' + i} onClick={() => onPick?.(h)} onPointerEnter={() => setHover({ ...h, x: cl.x, y: cl.y })} onPointerLeave={() => setHover(null)} style={{ cursor: 'pointer' }}>
                <circle cx={cl.x} cy={cl.y} r={Math.max(cl.size * 2.6, 9) / zs} fill="transparent" />
                <circle cx={cl.x} cy={cl.y} r={cl.size * 1.8 / zs} fill={S.halo} opacity={cl.fade} pointerEvents="none" />
                {h.book?.length > 0 && (
                  <circle cx={cl.x} cy={cl.y} r={(cl.size + 2.4) / zs} fill="none" stroke={VG.accent} strokeWidth={1.1 / zs} opacity={cl.fade} pointerEvents="none" />
                )}
                <circle cx={cl.x} cy={cl.y} r={cl.size / zs} fill={S.spot[h.layer] || S.spot.weather}
                  stroke={S.ocean} strokeWidth={0.5 / zs} opacity={0.85 * cl.fade} pointerEvents="none" />
              </g>
            );
          }
          const col = VG_LAYER_COLOR[cl.layer] || VG_LAYER_COLOR.weather;
          const size = Math.min(14, 6.5 + Math.log2(cl.count) * 2.2) / zs;
          const clBook = cl.members.some(m => m.book?.length);
          return (
            <g key={'c' + i} onClick={() => openCluster(cl)} style={{ cursor: 'pointer' }}
              onPointerEnter={() => setHover({ clusterCount: cl.count, x: cl.x, y: cl.y, names: cl.members.slice(0, 3).map(m => vgEventParts(m).name),
                book: cl.members.flatMap(m => (m.book || []).map(b => b.sym)) })}
              onPointerLeave={() => setHover(null)}>
              <circle cx={cl.x} cy={cl.y} r={Math.max(size * 2.2, 11)} fill="transparent" />
              <circle cx={cl.x} cy={cl.y} r={size * 2.6} fill={`url(#vg-cl-${cl.layer})`} opacity={0.9 * cl.fade} pointerEvents="none" />
              {clBook && <circle cx={cl.x} cy={cl.y} r={size + 3 / zs} fill="none" stroke={VG.accent} strokeWidth={1.2 / zs} opacity={cl.fade} pointerEvents="none" />}
              <circle cx={cl.x} cy={cl.y} r={size} fill={col} opacity={0.95 * cl.fade} stroke="rgba(0,0,0,0.55)" strokeWidth={1.1 / zs} pointerEvents="none" />
              <circle cx={cl.x} cy={cl.y} r={size} fill="rgba(0,0,0,0.30)" opacity={cl.fade} pointerEvents="none" />
              <text x={cl.x} y={cl.y + 3.4 / zs} textAnchor="middle" fontSize={10 / zs} fontWeight="700" fill="#fff" pointerEvents="none">{cl.count}</text>
            </g>
          );
        })}
        <circle r={R} fill="url(#vg-limb)" pointerEvents="none" />
        {card && (
          <g pointerEvents="none">
            <rect x={card.cx} y={card.cy} width={card.w} height={card.h} rx="7" fill="rgba(8,6,12,0.96)" stroke="rgba(244,242,247,0.20)" strokeWidth="0.8" />
            {card.lines.map((ln, i) => (
              <text key={i} x={card.cx + 8} y={card.cy + 16 + i * 15} fill={i === 0 ? '#f4f2f7' : '#b9b4c7'}
                fontSize={i === 0 ? 11.5 : 10.5} fontWeight={i === 0 ? 600 : 400} fontStyle={i === 2 ? 'italic' : 'normal'}>{ln}</text>
            ))}
          </g>
        )}
      </g>
    </svg>
  );
}

// Medium live monitor for the pulse tab: all layers, vanta shading, spinning;
// any interaction with a hotspot hands off to the full globe tab.
function VgPulseGlobe({ onGlobe }) {
  const [data, setData] = React.useState(undefined);
  const wrapRef = React.useRef(null);
  // Fetch only once the monitor is actually on screen — on a narrow viewport
  // it wraps below the fold, and the globe payload shouldn't cost anything
  // until it can be seen.
  React.useEffect(() => {
    let alive = true, fired = false;
    const load = () => {
      if (fired) return; fired = true;
      vgGet('/vantage/api/globe').then(d => alive && setData(d)).catch(() => alive && setData(null));
    };
    const el = wrapRef.current;
    if (el && typeof IntersectionObserver === 'function') {
      const io = new IntersectionObserver(es => { if (es[0]?.isIntersecting) { load(); io.disconnect(); } }, { threshold: 0.05 });
      io.observe(el);
      return () => { alive = false; io.disconnect(); };
    }
    load();
    return () => { alive = false; };
  }, []);
  const n = data ? ['weather', 'unrest', 'chatter'].reduce((a, k) => a + (data[k]?.length || 0), 0) : 0;
  // A compact monitor beside the read, not a hero above it. The per-layer
  // counts under the sphere are the skim: how much weather, how much conflict,
  // how much market chatter — and whether any of it touches YOUR book.
  return (
    <section ref={wrapRef} style={{
      background: VG.tile, borderRadius: 18, padding: '11px 16px 8px', marginBottom: 14,
      flex: '0 1 306px', maxWidth: '100%', alignSelf: 'flex-start',
    }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 }}>
        <span style={vgS.caps}>the globe · live</span>
        <span style={{ fontSize: 10.5, color: VG.ink4 }}>{data === null ? 'feeds unreachable' : n ? `${n} hotspots` : ''}</span>
        <span style={{ flex: 1 }} />
        <button onClick={onGlobe} className="vg-chip" style={{
          appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
          padding: '2px 10px', fontSize: 10.5, background: VG.chip, color: VG.ink3,
        }}>open →</button>
      </div>
      <div style={{ width: 272, maxWidth: '100%', margin: '0 auto' }}>
        <VgGlobeSvg data={data} layers={{ weather: true, unrest: true, chatter: true }}
          shade="vanta" spin frozen={false} onPick={h => onGlobe?.(h)} />
      </div>
      <div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap', fontSize: 10.5, marginTop: 3 }}>
        {data?.newToday > 0 && <span style={{ color: VG.accent2, fontWeight: 600 }}>{data.newToday} new today</span>}
        {data?.weather?.length > 0 && <span style={{ color: VG_LAYER_COLOR.weather }}>{data.weather.length} weather</span>}
        {data?.unrest?.length > 0 && <span style={{ color: VG_LAYER_COLOR.unrest }}>{data.unrest.length} events</span>}
        {data?.chatter?.length > 0 && <span style={{ color: VG_LAYER_COLOR.chatter }}>{data.chatter.length} news</span>}
        {data?.bookCount > 0 && <span style={{ color: VG.accent, fontWeight: 600 }}>{data.bookCount} touch your book</span>}
      </div>
    </section>
  );
}

function VantageGlobe({ onResearch, initialPick, onConsumedInitial }) {
  const [data, setData] = React.useState(undefined);       // undefined loading, null unreachable
  const [shade, setShade] = React.useState('vanta');
  const [layers, setLayers] = React.useState({ weather: true, unrest: true, chatter: true });
  const [bookOnly, setBookOnly] = React.useState(false);    // the composite lens: only what touches held/watched names
  const [spin, setSpin] = React.useState(true);
  const [picked, setPicked] = React.useState(null);         // a hotspot object
  const [claimSym, setClaimSym] = React.useState('');       // ticker to tie a hotspot claim to
  const [claimBusy, setClaimBusy] = React.useState(false);
  const [claimMsg, setClaimMsg] = React.useState(null);
  const [oracle, setOracle] = React.useState(null);         // for the "globe calls" ledger
  const [stats, setStats] = React.useState({ visible: 0, total: 0, live: null });
  // If the book empties while the lens is on, release it — a filter with an
  // empty universe would blank the globe with no visible control to undo it.
  React.useEffect(() => { if (data && !data.bookCount && bookOnly) setBookOnly(false); }, [data, bookOnly]);
  const [focus, setFocus] = React.useState(null);           // {lon, lat, z, at} — panel-driven recenter

  React.useEffect(() => {
    let alive = true;
    const load = () => vgGet('/vantage/api/globe')
      .then(d => alive && setData(d)).catch(() => alive && setData(null));
    load();
    const t = setInterval(() => { if (!document.hidden) load(); }, 10 * 60_000);
    return () => { alive = false; clearInterval(t); };
  }, []);

  const loadOracle = React.useCallback(() => {
    vgGet('/vantage/api/oracle').then(setOracle).catch(() => setOracle(null));
  }, []);
  React.useEffect(loadOracle, [loadOracle]);

  // Prefill the claim ticker from the hotspot's own tagged tickers when one opens.
  React.useEffect(() => { setClaimSym(picked?.tickers?.[0] || ''); setClaimMsg(null); }, [picked]);

  const trackClaim = async symArg => {
    const s = String(symArg ?? claimSym).trim().toUpperCase();
    if (!s || !picked) return;
    setClaimBusy(true); setClaimMsg(null);
    try {
      const r = await vgSend('/vantage/api/globe/claim', 'POST', {
        symbol: s, label: picked.label, layer: picked.layer, lat: picked.lat, lon: picked.lon,
      });
      setClaimMsg({ ok: true, text: r.registered ? `tracking ${s} — the oracle grades it from here` : `already tracking ${s} this week` });
      loadOracle();
    } catch (ex) { setClaimMsg({ ok: false, text: ex.message }); }
    setClaimBusy(false);
  };
  const globeClaims = (oracle?.claims || []).filter(c => c.kind === 'globe');

  // A hotspot handed in from the pulse-tab globe opens straight into its detail
  // panel — with its layer forced on and the spin paused for inspection — so the
  // click that brought you here actually explains itself.
  React.useEffect(() => {
    if (!initialPick) return;
    setPicked(initialPick);
    if (initialPick.layer) setLayers(s => ({ ...s, [initialPick.layer]: true }));
    setSpin(false);
    onConsumedInitial?.();
  }, [initialPick]);

  const chip = (on, label, act, color) => (
    <button key={label} onClick={act} className="vg-chip" style={{
      appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
      padding: '4px 12px', fontSize: 11, fontWeight: 600,
      background: on ? VG.tile2 : 'transparent',
      boxShadow: on ? 'none' : `inset 0 0 0 1px ${VG.rule}`,
      color: on ? (color || VG.ink) : VG.ink3,
    }}>{label}</button>
  );

  // Dark layers name their REASON when the feed reported one (GDELT's own
  // rejection text) — "dark" without "why" is a shrug, not a status.
  const darkArms = data ? VG_GLOBE_LAYERS.filter(([k]) =>
    k === 'weather' ? (!data.arms.eonet && !data.arms.quakes) : !data.arms[k === 'unrest' ? 'unrest' : 'chatter']
  ).map(([k, lbl]) => {
    const why = data.armErrors?.[k];
    return lbl.split(' · ')[0] + (why ? ` (feed says: “${String(why).slice(0, 70)}”)` : '');
  }) : [];

  return (
    <React.Fragment>
      <section style={{ background: VG.tile, borderRadius: 18, padding: '13px 18px', marginBottom: 12 }}>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
          <span style={vgS.caps}>the globe · what's happening on earth right now</span>
          {data?.newToday > 0 && <span style={{ ...vgS.caps, color: VG.accent2 }}>{data.newToday} new today</span>}
          <span style={{ flex: 1 }} />
          {VG_GLOBE_LAYERS.map(([k, lbl]) =>
            chip(layers[k], lbl.split(' · ')[0], () => setLayers(s => ({ ...s, [k]: !s[k] })), VG_LAYER_COLOR[k]))}
          {/* Gradual introduction: the my-book lens appears once the book can
              light anything up; with nothing held or watched it stays a quiet
              promise instead of a dead filter. */}
          {data?.bookCount > 0
            ? chip(bookOnly, `my book (${data.bookCount})`, () => setBookOnly(b => !b), VG.accent)
            : data ? <span style={{ fontSize: 10.5, color: VG.ink4, alignSelf: 'center' }}>{vgFeatureIntro({ bookCount: 0 }).bookLens.hint}</span> : null}
          <span style={{ width: 10 }} />
          {chip(shade === 'vanta', 'vanta black', () => setShade('vanta'))}
          {chip(shade === 'natural', 'natural earth', () => setShade('natural'))}
          {chip(shade === 'live', 'live weather', () => setShade('live'))}
          {chip(spin, spin ? 'spinning' : 'spin', () => setSpin(s => !s))}
        </div>
        {data === null && <div style={{ color: VG.ink3, fontSize: 12.5, marginTop: 8 }}>globe feeds unreachable — the planet returns when the network does.</div>}
        {data && darkArms.length > 0 && (
          <div style={{ color: VG.ink4, fontSize: 11, marginTop: 8 }}>dark layers right now: {darkArms.join(', ')} — they return automatically.</div>
        )}
      </section>

      <section style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(300px, 1fr)', gap: 14, marginBottom: 14, alignItems: 'start' }}>
        <div style={{ background: VG.tile, borderRadius: 18, padding: 10, minWidth: 0 }}>
          {data === undefined && <div style={{ ...vgS.serif, color: VG.ink3, fontSize: 15, padding: '18px 8px' }}>finding the planet…</div>}
          <div style={{ maxWidth: 540, margin: '0 auto' }}>
          <VgGlobeSvg data={data} layers={layers} shade={shade} spin={spin} frozen={!!picked} bookOnly={bookOnly} focus={focus}
            onPick={h => setPicked(h)} onUserSpin={setSpin} zoomable
            onStats={(visible, total, live) => setStats(p => (p.visible === visible && p.total === total && p.live === live) ? p : { visible, total, live })} />
          </div>
          <div style={{ display: 'flex', gap: 12, padding: '6px 8px 2px', fontSize: 10.5, color: VG.ink4, flexWrap: 'wrap', alignItems: 'baseline' }}>
            <span>click any mark to inspect · drag to rotate · scroll to zoom · double-click resets</span>
            <span style={{ flex: 1 }} />
            {data?.stale && <span>refreshing feeds…</span>}
            {stats.live?.state === 'loading' && <span style={{ color: VG.accent2 }}>fetching earth imagery…</span>}
            {stats.live?.state === 'error' && <span style={{ color: '#fb923c' }}>live imagery unavailable — showing the vector globe</span>}
            {stats.live?.state === 'ready' && (
              <span>nasa blue marble + imerg precipitation · latest published (lags realtime by a few hours){stats.live.precip ? '' : ' · precip layer unavailable right now'}</span>
            )}
            {stats.total > 0 && (
              <span style={vgS.num}>
                showing {stats.visible} of {stats.total}
                {stats.total > stats.visible ? ` · ${stats.total - stats.visible} on the far side — rotate to see them` : ''}
              </span>
            )}
          </div>
        </div>

        <div style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', minWidth: 0 }}>
          <div style={{ ...vgS.caps, marginBottom: 8 }}>{picked ? 'hotspot' : 'the point of the globe'}</div>
          {!picked && (
            <div style={{ fontSize: 12.5, color: VG.ink3, lineHeight: 1.6 }}>
              A hurricane near a port, a strike in a lithium belt, a tariff story catching fire in one
              region — physical events reach portfolios through supply chains before they reach
              headlines’ front pages. Filter the layers, click a hotspot to see the stories behind it,
              and when a ticker surfaces, open its dossier without leaving the flow.
            </div>
          )}
          {picked?.clusterList && (
            <React.Fragment>
              <div style={{ ...vgS.caps, color: VG_LAYER_COLOR[picked.layer] || VG.accent2, marginBottom: 3 }}>cluster</div>
              <div style={{ fontSize: 15, color: VG.ink, fontWeight: 600, marginBottom: 8 }}>{picked.label}</div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginBottom: 10 }}>
                {picked.clusterList.map((m, i) => {
                  const ep = vgEventParts(m);
                  return (
                    <button key={i} onClick={() => setPicked(m)} style={{
                      appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer', textAlign: 'left',
                      padding: '6px 8px', borderRadius: 8, borderBottom: i < picked.clusterList.length - 1 ? `1px solid ${VG.rule}` : 'none',
                    }}>
                      <div style={{ ...vgS.caps, fontSize: 8.5, color: VG_LAYER_COLOR[m.layer] || VG.ink4 }}>
                        {m.kind}{ep.region ? ` · ${ep.region}` : ''}
                        {m.book?.length > 0 && <span style={{ color: VG.accent }}> · {m.book.map(b => b.sym).join(' ')}</span>}
                      </div>
                      <div style={{ fontSize: 12.5, color: VG.ink2, lineHeight: 1.35 }}>{ep.name}</div>
                    </button>
                  );
                })}
              </div>
              <div style={{ display: 'flex', gap: 8 }}>
                <VgBtn small onClick={() => setFocus({ lon: picked.lon, lat: picked.lat, z: 3.2, at: Date.now() })}>zoom into this area</VgBtn>
                <VgBtn small onClick={() => setPicked(null)}>close</VgBtn>
              </div>
            </React.Fragment>
          )}
          {picked && !picked.clusterList && (
            <React.Fragment>
              {/* Skim hierarchy: TYPE · REGION (broadest first) → the event's
                  name → the numbers → then context & coverage below. */}
              <div style={{ ...vgS.caps, color: VG_LAYER_COLOR[picked.layer] || VG.accent2, marginBottom: 3 }}>
                {picked.kind}{vgEventParts(picked).region ? ` · ${vgEventParts(picked).region}` : ''}
              </div>
              <div style={{ fontSize: 15.5, color: VG.ink, fontWeight: 600, lineHeight: 1.35, marginBottom: 5 }}>{vgEventParts(picked).name}</div>
              <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 11, color: VG.ink3, marginBottom: 10 }}>
                {picked.date && <span>{String(picked.date).slice(0, 10)}</span>}
                {picked.mag != null && <span>magnitude {picked.mag}</span>}
                {picked.count != null && picked.layer !== 'weather' && <span>{picked.count} mentions · 2d</span>}
                <span style={{ ...vgS.num }}>{picked.lat}°, {picked.lon}°</span>
              </div>

              {/* The three questions a dot can't answer: is it new here, how
                  bad is it, and what economically load-bearing places sit
                  nearby. Novelty is the globe's own memory; severity is only
                  what the feed reported; nearby is a curated heuristic map. */}
              {(picked.novelty || picked.severity) && (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginBottom: 10, fontSize: 12, color: VG.ink2 }}>
                  {picked.novelty && (
                    <div>
                      <span style={{ ...vgS.caps, marginRight: 6 }}>since</span>
                      <span style={{ color: /new to the globe|first seen today/.test(picked.novelty) ? VG.accent2 : VG.ink2 }}>{picked.novelty}</span>
                    </div>
                  )}
                  {picked.severity && (
                    <div>
                      <span style={{ ...vgS.caps, marginRight: 6 }}>how bad</span>
                      <span style={{ color: ['major', 'hurricane', 'roaring'].includes(picked.severity.tier) ? '#fb923c' : VG.ink2 }}>{picked.severity.label}</span>
                    </div>
                  )}
                </div>
              )}
              {picked.econ?.length > 0 && (
                <div style={{ marginBottom: 10 }}>
                  <div style={{ ...vgS.caps, marginBottom: 4 }}>what's near it · curated map, heuristic</div>
                  {picked.econ.map((e, i) => (
                    <div key={i} style={{ fontSize: 12, color: VG.ink2, lineHeight: 1.5, marginBottom: 3 }}>
                      <span style={{ color: VG.ink }}>{e.label}</span>
                      <span style={{ ...vgS.num, fontSize: 10.5, color: VG.ink4 }}> · ~{e.km}km</span>
                      <span style={{ color: VG.ink3 }}> — {e.what}</span>
                      {e.syms?.length > 0 && e.syms.map(sy => (
                        <button key={sy} onClick={() => onResearch?.(sy)} style={{
                          appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer',
                          color: VG.accent2, fontSize: 11.5, fontWeight: 700, padding: '0 0 0 7px',
                        }}>{sy} →</button>
                      ))}
                    </div>
                  ))}
                </div>
              )}
              {picked.econ?.length === 0 && picked.layer === 'weather' && (
                <div style={{ fontSize: 11, color: VG.ink4, marginBottom: 10 }}>
                  nothing on the economic map within range — likely open ocean or far from tracked infrastructure.
                </div>
              )}

              {/* The composite read: this event × YOUR book. The strongest line
                  on the panel — it's the reason the globe exists. */}
              {picked.book?.length > 0 && (
                <div style={{ background: 'rgba(217,70,239,0.10)', borderRadius: 10, padding: '8px 12px', marginBottom: 8 }}>
                  <span style={{ ...vgS.caps, color: VG.accent }}>in your book · </span>
                  {picked.book.map(b => (
                    <button key={b.sym} onClick={() => onResearch?.(b.sym)} style={{
                      appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer',
                      color: VG.accent2, fontSize: 12.5, fontWeight: 700, padding: '0 6px 0 0',
                    }}>{b.sym} ({b.held ? 'held' : 'watching'}) →</button>
                  ))}
                </div>
              )}

              {/* The indirect read: what this kind of event, here, tends to mean
                  for markets. A prior, labeled as such — never data. */}
              {picked.hint && (
                <div style={{ marginBottom: 10 }}>
                  <div style={{ ...vgS.caps, marginBottom: 4 }}>indirect read · heuristic, not data</div>
                  <div style={{ fontSize: 12, color: VG.ink2, lineHeight: 1.5, marginBottom: 5 }}>{picked.hint.read}</div>
                  {picked.hint.etfs?.length > 0 && (
                    <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                      {picked.hint.etfs.map(t => (
                        <button key={t} className="vg-chip" onClick={() => onResearch?.(t)} style={{
                          appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
                          padding: '3px 10px', fontSize: 11, fontWeight: 600, background: VG.chip, color: VG.ink2,
                        }}>{t} dossier →</button>
                      ))}
                    </div>
                  )}
                </div>
              )}

              <div style={{ ...vgS.caps, marginBottom: 5 }}>
                context & coverage{(picked.links || []).length > 1 ? ` · ${picked.links.length} outlets` : ''}
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
                {(picked.links || []).map((l, i) => {
                  let host = null;
                  try { host = new URL(l.url).hostname.replace(/^www\./, ''); } catch { /* leave null */ }
                  return (
                    <a key={i} href={l.url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: VG.ink2, lineHeight: 1.45 }}>
                      {vgSourceTitle(l.title)} ↗{host && <span style={{ color: VG.ink4, fontSize: 10.5 }}> · {host}</span>}
                    </a>
                  );
                })}
                {(picked.links || []).length === 0 && (
                  <span style={{ fontSize: 11.5, color: VG.ink4 }}>no source articles carried on this point — search is your friend:</span>
                )}
                <a href={vgNewsSearch(picked)} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: VG.accent2 }}>
                  search news coverage of this event ↗
                </a>
              </div>
              {(picked.tickers || []).length > 0 && (
                <div style={{ marginBottom: 10 }}>
                  <div style={{ ...vgS.caps, marginBottom: 5 }}>tickers in the coverage</div>
                  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                    {picked.tickers.map(t => (
                      <button key={t} className="vg-chip" onClick={() => onResearch?.(t)} style={{
                        appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
                        padding: '4px 12px', fontSize: 11.5, fontWeight: 700,
                        background: VG.accent, color: '#0a0208',
                      }}>open {t} dossier →</button>
                    ))}
                  </div>
                </div>
              )}
              {(() => {
                // Only offer the scoreboard when there's something honest to
                // tie the event to — book names, tagged tickers, or the hint's
                // ETFs. A random fire with no market link gets no dead box.
                const candidates = [...new Set([
                  ...(picked.book || []).map(b => b.sym),
                  ...(picked.tickers || []),
                  ...(picked.hint?.etfs || []),
                ])].slice(0, 4);
                if (!candidates.length && picked.layer === 'weather') return null;
                return (
                  <div style={{ borderTop: `1px solid ${VG.rule}`, marginTop: 4, paddingTop: 10, marginBottom: 10 }}>
                    <div style={{ ...vgS.caps, marginBottom: 5 }}>follow the market impact</div>
                    <div style={{ fontSize: 11.5, color: VG.ink3, lineHeight: 1.5, marginBottom: 8 }}>
                      think this event moves a stock? Track it and the oracle keeps score — how that ticker
                      does vs the S&P over the next 1, 5 and 20 days. A scoreboard for your read, not a trade.
                    </div>
                    {candidates.length > 0 && (
                      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
                        {candidates.map(t => (
                          <button key={t} className="vg-chip" disabled={claimBusy} onClick={() => trackClaim(t)} style={{
                            appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
                            padding: '4px 12px', fontSize: 11.5, fontWeight: 700, background: VG.accent, color: '#0a0208',
                          }}>track {t}</button>
                        ))}
                      </div>
                    )}
                    <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
                      <input value={claimSym} onChange={e => setClaimSym(e.target.value.toUpperCase())} placeholder="or any ticker"
                        onKeyDown={e => { if (e.key === 'Enter') trackClaim(); }}
                        style={{ width: 110, background: VG.chip, border: `1px solid ${VG.rule}`, borderRadius: 8, color: VG.ink, padding: '6px 10px', fontSize: 12, textTransform: 'uppercase' }} />
                      <VgBtn small disabled={claimBusy || !claimSym.trim()} onClick={() => trackClaim()}>track</VgBtn>
                      {claimMsg && <span style={{ fontSize: 11.5, color: claimMsg.ok ? VG.up : VG.down }}>{claimMsg.text}</span>}
                    </div>
                  </div>
                );
              })()}
              <VgBtn small onClick={() => setPicked(null)}>close</VgBtn>
            </React.Fragment>
          )}
          {!picked && globeClaims.length > 0 && (
            <div style={{ marginTop: 14, borderTop: `1px solid ${VG.rule}`, paddingTop: 12 }}>
              <div style={{ ...vgS.caps, marginBottom: 8 }}>your globe calls · scored vs the S&P</div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
                {globeClaims.slice(0, 6).map(c => {
                  const t5 = c.outcomes?.t5;
                  return (
                    <div key={c.id} style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: 11.5 }}>
                      <span style={{ ...vgS.caps, color: VG.accent2 }}>{c.focus_symbol}</span>
                      <span style={{ color: VG.ink3, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
                      {t5 ? (
                        <span style={{ ...vgS.num, color: t5.focus != null ? (t5.focus > (t5.spx ?? 0) ? VG.up : VG.down) : VG.ink3 }}
                          title={`t+5 · ${c.focus_symbol} ${t5.focus != null ? (t5.focus * 100).toFixed(1) + '%' : '—'} vs S&P ${(t5.spx * 100).toFixed(1)}%`}>
                          t+5 {t5.focus != null ? (t5.focus > 0 ? '+' : '') + (t5.focus * 100).toFixed(1) + '%' : '—'}
                        </span>
                      ) : (
                        <span style={{ fontSize: 10.5, color: VG.ink4 }}>pending</span>
                      )}
                    </div>
                  );
                })}
              </div>
            </div>
          )}
        </div>
      </section>
    </React.Fragment>
  );
}

window.VantageGlobe = VantageGlobe;
window.VgGlobeSvg = VgGlobeSvg;
window.VgPulseGlobe = VgPulseGlobe;
