/* Globe — d3.geoOrthographic with real coastlines and clickable HQ pills.
 *
 * Coastlines: world-atlas land-110m TopoJSON, fetched once and cached on
 * window so route changes don't refetch. Until it loads, ocean + graticule
 * still render — coastlines stream in.
 *
 * Projection: orthographic at TILT=-15 (top leans right) and PITCH=-10
 * (slight downward look toward the equator), to match the "Globe Loader"
 * stylistic reference.
 *
 * Labels: always-visible pill next to every front-facing HQ. HQs that
 * project within ~22 px of each other (e.g. the Johannesburg/Sandton/
 * Westonaria cluster) are stacked vertically off the cluster centroid so
 * the pills don't pile on top of each other. The pill is the click target
 * for opening that company's scorecard — its button-style framing is what
 * makes the click affordance obvious.
 */
const COMPANY_HQS = [
  { id: 'bhp',       name: 'BHP',                city: 'Melbourne',    lat: -37.8136, lon:  144.9631 },
  { id: 'south32',   name: 'South32',            city: 'Perth',        lat: -31.9523, lon:  115.8613 },
  { id: 'mmg',       name: 'MMG',                city: 'Melbourne',    lat: -37.8231, lon:  144.9667 },
  { id: 'orano',     name: 'Orano',              city: 'Paris',        lat:  48.8566, lon:    2.3522 },
  { id: 'anglo',     name: 'Anglo American',     city: 'London',       lat:  51.5074, lon:   -0.1278 },
  { id: 'goldf',     name: 'Gold Fields',        city: 'Johannesburg', lat: -26.2041, lon:   28.0473 },
  { id: 'sibanye',   name: 'Sibanye-Stillwater', city: 'Westonaria',   lat: -26.3164, lon:   27.6562 },
  { id: 'anglogold', name: 'AngloGold Ashanti',  city: 'Sandton',      lat: -26.1067, lon:   28.0568 },
  { id: 'antofa',    name: 'Antofagasta',        city: 'Santiago',     lat: -33.4489, lon:  -70.6693 },
  { id: 'newmont',   name: 'Newmont',            city: 'Denver',       lat:  39.7392, lon: -104.9903 },
  { id: 'freeport',  name: 'Freeport-McMoRan',   city: 'Phoenix',      lat:  33.4484, lon: -112.0740 },
  { id: 'barrick',   name: 'Barrick',            city: 'Toronto',      lat:  43.6532, lon:  -79.3832 },
  { id: 'teck',      name: 'Teck',               city: 'Vancouver',    lat:  49.2827, lon: -123.1207 },
];

// Ambient headline strip that scrolls behind the globe. All values come from
// window.KESTREL_SUMMARY (data/kestrel_public.json) — never hardcode. Items
// repeat twice in the rendered track so the keyframe animation can loop
// seamlessly with a translateX(-50%) endpoint.
//
// Per-company line picks: each id below names which metric template that
// company highlights. Editorial choice (so the ticker stays varied across
// 13 lines), but every value and every superlative label is recomputed at
// render time from KESTREL_SUMMARY.
const GLOBE_COMPANY_TICKER_PICKS = {
  bhp:       'n_passages',
  orano:     'transformational',
  sibanye:   'value_chain',
  antofa:    'positive_pct',
  newmont:   'positive_pct',
  barrick:   'avg_confidence',
  anglo:     'cluster_development',
  goldf:     'positive_pct',
  teck:      'n_passages',
  south32:   'ambiguous_pct',
  freeport:  'positive_pct',
  anglogold: 'n_passages',
  mmg:       'ambiguous_pct',
};

const COUNTRY_TO_CONTINENT = {
  'Australia':      'Oceania',
  'France':         'Europe',
  'United Kingdom': 'Europe',
  'UK':             'Europe',
  'South Africa':   'Africa',
  'Chile':          'South America',
  'United States':  'North America',
  'USA':            'North America',
  'Canada':         'North America',
};

const NUMBER_WORDS = ['Zero','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Ten','Eleven','Twelve'];

function formatPct(v) {
  return (v ?? 0).toFixed(2) + '%';
}

function buildGlobeTickerStats(summary) {
  if (!summary) return [];
  const meta = summary.meta || {};
  const g = summary.global || {};
  const labelPct = g.label_pct || {};
  const companies = summary.companies || [];

  const continents = new Set(
    companies.map(c => COUNTRY_TO_CONTINENT[c.country]).filter(Boolean)
  );
  const yearLo = meta.year_range?.[0];
  const yearHi = meta.year_range?.[1];
  const yearsSpan = (yearLo != null && yearHi != null) ? (yearHi - yearLo + 1) : 0;
  const yearWord = NUMBER_WORDS[yearsSpan] || String(yearsSpan);

  const global = [
    `${(meta.total_passages ?? 0).toLocaleString()} disclosure passages classified`,
    `${meta.n_companies ?? companies.length} mining majors · ${continents.size} continents`,
    `${yearWord} reporting years · ${yearLo} — ${yearHi}`,
    `${formatPct(labelPct.positive)} positive shared value`,
    `${formatPct(labelPct.negative)} rated negative or absent`,
  ];

  // Resolve cohort superlatives from current data, not hardcoded.
  const byPositive = [...companies].sort((a, b) => b.positive_pct - a.positive_pct);
  const byAmbiguous = [...companies].sort((a, b) => b.ambiguous_pct - a.ambiguous_pct);
  const highestPosId = byPositive[0]?.id;
  const lowestPosId = byPositive[byPositive.length - 1]?.id;
  const highestAmbId = byAmbiguous[0]?.id;

  const lineFor = (c) => {
    const pick = GLOBE_COMPANY_TICKER_PICKS[c.id] || 'n_passages';
    // Commodity is title-cased in the data layer; the ticker reads as lowercase
    // editorial style ("iron, copper, coal"), so normalise here. All-caps
    // tokens are presumed acronyms (e.g. "PGM") and preserved.
    const commodityTail = (c.commodity || '')
      .split(/,\s*/)
      .map(s => /^[A-Z]+$/.test(s) ? s : s.toLowerCase())
      .join(', ');
    const tail = ` · ${commodityTail}`;
    switch (pick) {
      case 'n_passages':
        return `${c.name} · ${(c.n_passages ?? 0).toLocaleString()} passages analysed${tail}`;
      case 'transformational': {
        const n = c.initiative_type?.transformational ?? 0;
        return `${c.name} · ${n} transformational CSV initiative${n === 1 ? '' : 's'}${tail}`;
      }
      case 'value_chain': {
        const n = c.csv_pillar?.value_chain ?? 0;
        return `${c.name} · ${n.toLocaleString()} value-chain passages${tail}`;
      }
      case 'cluster_development': {
        const n = c.csv_pillar?.cluster_development ?? 0;
        return `${c.name} · ${n.toLocaleString()} cluster-development passages${tail}`;
      }
      case 'avg_confidence':
        return `${c.name} · ${(c.avg_confidence ?? 0).toFixed(1)} average confidence${tail}`;
      case 'positive_pct': {
        let suffix = ' shared value';
        if (c.id === highestPosId) suffix = ' — highest in cohort';
        else if (c.id === lowestPosId) suffix = ' — lowest in cohort';
        return `${c.name} · ${formatPct(c.positive_pct)} positive${suffix}${tail}`;
      }
      case 'ambiguous_pct': {
        const suffix = c.id === highestAmbId ? ' — highest in cohort' : '';
        return `${c.name} · ${formatPct(c.ambiguous_pct)} ambiguous${suffix}${tail}`;
      }
      default:
        return `${c.name} · ${(c.n_passages ?? 0).toLocaleString()} passages analysed${tail}`;
    }
  };

  // Companies are pre-sorted by n_passages desc in kestrel_public.json.
  const perCompany = companies.map(lineFor);
  return global.concat(perCompany);
}

const ROTATION_DEG_PER_SEC = 5;     // ~72 s per revolution
const TILT  = -15;                   // d3 rotate roll axis (constant)
const INITIAL_PITCH = -10;           // starting latitude tilt (user can drag)
const DRAG_SENS = 0.35;              // degrees per pixel of pointer movement
const DRAG_THRESHOLD_PX = 4;         // movement below this counts as a click, not a drag
const PAUSE_TAU_SEC = 0.32;          // exponential ease time constant for pause/resume
const SCALE = 230;                   // orthographic scale (~ disc radius)
const SIZE  = 540;
const CX    = SIZE / 2;
const CY    = SIZE / 2;
const LAND_URL = 'https://unpkg.com/world-atlas@2.0.2/land-110m.json';
const LAND_CACHE_KEY = '__kestrelLand';

const Globe = ({ setRoute }) => {
  const summary = window.KESTREL_SUMMARY;
  const tickerStats = React.useMemo(() => buildGlobeTickerStats(summary), [summary]);
  const nMajors = summary?.meta?.n_companies ?? COMPANY_HQS.length;
  const continentCount = React.useMemo(() => {
    const set = new Set((summary?.companies || []).map(c => COUNTRY_TO_CONTINENT[c.country]).filter(Boolean));
    return set.size || 5;
  }, [summary]);
  const continentWord = NUMBER_WORDS[continentCount]?.toLowerCase() || String(continentCount);
  const [angle, setAngle]   = React.useState(0);
  const [pitch, setPitch]   = React.useState(INITIAL_PITCH);
  const [land, setLand]     = React.useState(window[LAND_CACHE_KEY] || null);
  const [hoverId, setHover] = React.useState(null);
  const [d3Ready, setD3Ready] = React.useState(!!window.d3);
  const [dragging, setDragging] = React.useState(false);
  // Mutable drag state — kept in a ref so pointermove updates don't
  // re-render until the angle actually changes.
  const dragRef = React.useRef(null);
  // Set to true on pointerup if the gesture exceeded the click threshold.
  // The pill click handler reads-and-clears it to suppress the navigation
  // that would otherwise fire at the end of a drag.
  const justDraggedRef = React.useRef(false);
  // Pill renders only on hover — but the cursor has to cross a small gap
  // between the dot's hit circle and the pill itself. Without a grace
  // window the pill un-renders mid-traverse and the click never lands.
  const clearHoverTimerRef = React.useRef(null);
  const cancelClearHover = () => {
    if (clearHoverTimerRef.current) {
      clearTimeout(clearHoverTimerRef.current);
      clearHoverTimerRef.current = null;
    }
  };
  const scheduleClearHover = (id) => {
    cancelClearHover();
    clearHoverTimerRef.current = setTimeout(() => {
      setHover(prev => prev === id ? null : prev);
      clearHoverTimerRef.current = null;
    }, 140);
  };
  React.useEffect(() => () => cancelClearHover(), []);

  const reduced = React.useMemo(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return false;
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  }, []);

  // Wait for d3 / topojson to finish loading from CDN.
  React.useEffect(() => {
    if (window.d3 && window.topojson) { setD3Ready(true); return; }
    const id = setInterval(() => {
      if (window.d3 && window.topojson) {
        setD3Ready(true);
        clearInterval(id);
      }
    }, 80);
    return () => clearInterval(id);
  }, []);

  // Load and cache the land geometry once globally.
  React.useEffect(() => {
    if (!d3Ready || land) return;
    let stale = false;
    fetch(LAND_URL).then(r => r.json()).then(topo => {
      if (stale) return;
      const f = window.topojson.feature(topo, topo.objects.land);
      window[LAND_CACHE_KEY] = f;
      setLand(f);
    }).catch(()=>{});
    return () => { stale = true; };
  }, [d3Ready, land]);

  // Rotation animation. The rAF loop runs continuously (when reduced-motion
  // isn't preferred); a "speed factor" eases between 0 and 1 so the globe
  // gracefully winds down on hover and ramps back up on release. Drag is a
  // hard pause — the user is steering the globe, so we don't want the auto
  // rotation drifting underneath their gesture.
  const speedRef    = React.useRef(1);
  const hoverIdRef  = React.useRef(hoverId);
  const draggingRef = React.useRef(dragging);
  React.useEffect(() => { hoverIdRef.current  = hoverId;  }, [hoverId]);
  React.useEffect(() => {
    draggingRef.current = dragging;
    if (dragging) speedRef.current = 0;
  }, [dragging]);

  React.useEffect(() => {
    if (reduced) { speedRef.current = 0; return; }
    let raf;
    let last = performance.now();
    const tick = (now) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      const target = (hoverIdRef.current || draggingRef.current) ? 0 : 1;
      if (draggingRef.current) {
        speedRef.current = 0;
      } else {
        const k = 1 - Math.exp(-dt / PAUSE_TAU_SEC);
        speedRef.current += (target - speedRef.current) * k;
      }
      if (speedRef.current > 0.001) {
        setAngle(a => (a + dt * ROTATION_DEG_PER_SEC * speedRef.current) % 360);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [reduced]);

  // Build the projection + paths. We re-create the projection each render
  // so React state owns the rotation, and d3 just translates state → SVG.
  let sphereD = '', landD = '', gratD = '';
  let projectFn = null;
  if (d3Ready) {
    const d3 = window.d3;
    const projection = d3.geoOrthographic()
      .scale(SCALE)
      .translate([CX, CY])
      .clipAngle(90)
      .rotate([angle, pitch, TILT]);
    const pathGen = d3.geoPath(projection);
    sphereD = pathGen({ type: 'Sphere' }) || '';
    landD   = land ? (pathGen(land) || '') : '';
    gratD   = pathGen(d3.geoGraticule().step([20, 15])()) || '';
    projectFn = projection;
  }

  // Project HQs that are on the front face. d3's raw projector ignores
  // clipAngle (that only applies inside the geoPath stream), so we check
  // visibility ourselves via great-circle distance to the view centre.
  const visible = [];
  if (projectFn) {
    const d3 = window.d3;
    const r = projectFn.rotate();
    const centre = [-r[0], -r[1]];
    const limit = Math.PI / 2 - 0.04;  // small buffer so dots don't sit on the limb
    COMPANY_HQS.forEach(hq => {
      if (d3.geoDistance([hq.lon, hq.lat], centre) < limit) {
        visible.push({ hq, xy: projectFn([hq.lon, hq.lat]) });
      }
    });
  }

  // Cluster nearby HQs (Johannesburg metro, Melbourne) so each member
  // gets its own pill stacked vertically off the cluster centroid. The
  // whole cluster shares one hover state — hovering any dot reveals
  // all stacked labels — so users can find every HQ at the same point.
  const clusters = [];
  visible.forEach(d => {
    const bin = clusters.find(c =>
      Math.hypot(c.cx - d.xy[0], c.cy - d.xy[1]) < 22);
    if (bin) {
      bin.items.push(d);
      bin.cx = bin.items.reduce((s, x) => s + x.xy[0], 0) / bin.items.length;
      bin.cy = bin.items.reduce((s, x) => s + x.xy[1], 0) / bin.items.length;
    } else {
      clusters.push({ cx: d.xy[0], cy: d.xy[1], items: [d] });
    }
  });

  clusters.forEach(c => {
    c.items.sort((a, b) => a.hq.id.localeCompare(b.hq.id));
    c.id = c.items.map(d => d.hq.id).join('+');
    c.side = c.cx > CX ? 'R' : 'L';
    c.labels = c.items.map((d, i) => ({
      ...d,
      side: c.side,
      anchorY: c.cy + (i - (c.items.length - 1) / 2) * 24,
    }));
  });

  return (
    <section className="section">
      <div className="site-wrap">
        <div style={{maxWidth:680,margin:'0 auto',textAlign:'center'}}>
          <EyebrowRow label="Where they're based"/>
          <h2 style={{marginTop:18}}>
            Mining HQs across <span className="serif-moment">{continentWord} continents</span>.
          </h2>
          <p className="lede" style={{marginTop:18,color:'var(--ink-2)'}}>
            The {nMajors} mining majors covered by Kestrel are headquartered in {new Set(COMPANY_HQS.map(h=>h.city)).size} cities across {continentWord} continents.
          </p>
          <div style={{
            marginTop:20,display:'inline-flex',alignItems:'center',gap:8,
            padding:'8px 14px',borderRadius:999,
            border:'1px solid var(--moss)',background:'var(--sage-tint, #EDF1E6)',
            fontSize:13,fontWeight:500,color:'var(--moss-deep)',
          }}>
            <Icon name="arrow-right" size={12}/>
            Drag to rotate · click any label to open the scorecard
          </div>
          <div style={{marginTop:18}}>
            <button className="btn btn-text" style={{fontSize:13}} onClick={()=>setRoute({page:'explore'})}>
              Or browse the full company list <Icon name="arrow-right" size={12} style={{marginLeft:2}} className="arrow"/>
            </button>
          </div>
        </div>

        <div style={{marginTop:48}}>
          <div className="globe-stage" style={{margin:'0 auto'}}>
          {/* Ambient ticker — z-index 0, sits behind the globe SVG. The
              opaque ocean disc occludes the strip as it crosses; the
              horizontal mask in site.css fades text to ~0 right at the
              disc edge so it reads as "passing behind" rather than
              clipping. aria-hidden because the same stats are surfaced in
              accessible form elsewhere on the page. */}
          <div className="globe-ticker" aria-hidden="true">
            <div className="globe-ticker-track">
              {tickerStats.concat(tickerStats).map((stat, i) => (
                <span className="globe-ticker-item" key={i}>
                  <span className="globe-ticker-bullet"/>
                  {stat}
                </span>
              ))}
            </div>
          </div>
          <svg viewBox={`0 0 ${SIZE} ${SIZE}`}
              tabIndex={-1}
              style={{
                width:'100%',height:'auto',display:'block',
                overflow:'visible',
                cursor: dragging ? 'grabbing' : 'grab',
                touchAction: 'none',
                userSelect: 'none',
                WebkitUserSelect: 'none',
                WebkitTapHighlightColor: 'transparent',
                outline: 'none',
              }}
              onPointerDown={(e) => {
                dragRef.current = {
                  pointerId: e.pointerId,
                  startX: e.clientX, startY: e.clientY,
                  startAngle: angle, startPitch: pitch,
                  moved: false,
                  captured: false,
                };
                // Pointer capture is deferred until we know this is a drag
                // (see onPointerMove). Capturing on pointerdown would redirect
                // the subsequent mouseup/click to the SVG, so a tap on a pill
                // would never reach the pill's own onClick handler.
              }}
              onPointerMove={(e) => {
                if (!dragRef.current) return;
                const dx = e.clientX - dragRef.current.startX;
                const dy = e.clientY - dragRef.current.startY;
                if (!dragRef.current.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
                  dragRef.current.moved = true;
                  setDragging(true);
                  // Now it's definitely a drag — capture the pointer so a
                  // gesture that wanders off the SVG keeps steering it, and
                  // drop the hover so labels don't drift with rotation.
                  if (e.currentTarget.setPointerCapture) {
                    try {
                      e.currentTarget.setPointerCapture(dragRef.current.pointerId);
                      dragRef.current.captured = true;
                    } catch (_) {}
                  }
                  setHover(null);
                }
                if (dragRef.current.moved) {
                  setAngle(((dragRef.current.startAngle + dx * DRAG_SENS) % 360 + 360) % 360);
                  setPitch(Math.max(-85, Math.min(85, dragRef.current.startPitch - dy * DRAG_SENS)));
                }
              }}
              onPointerUp={(e) => {
                if (!dragRef.current) return;
                if (dragRef.current.captured && e.currentTarget.releasePointerCapture) {
                  try { e.currentTarget.releasePointerCapture(dragRef.current.pointerId); } catch (_) {}
                }
                if (dragRef.current.moved) {
                  // Suppress the click that browsers fire after pointerup on
                  // the original pointerdown target.
                  justDraggedRef.current = true;
                  setDragging(false);
                }
                dragRef.current = null;
              }}
              onPointerCancel={(e) => {
                if (!dragRef.current) return;
                if (dragRef.current.captured && e.currentTarget.releasePointerCapture) {
                  try { e.currentTarget.releasePointerCapture(dragRef.current.pointerId); } catch (_) {}
                }
                if (dragRef.current.moved) setDragging(false);
                dragRef.current = null;
              }}
              role="img" aria-label="Drag-rotatable globe with clickable head-office markers"
              preserveAspectRatio="xMidYMid meet">
              <defs>
                <radialGradient id="globe-ocean" cx="38%" cy="32%" r="78%">
                  <stop offset="0%"   stopColor="#F4F6EE" stopOpacity="1"/>
                  <stop offset="55%"  stopColor="#E5EADC" stopOpacity="1"/>
                  <stop offset="100%" stopColor="#CFD6C2" stopOpacity="1"/>
                </radialGradient>
                <radialGradient id="globe-rim" cx="50%" cy="50%" r="50%">
                  <stop offset="92%" stopColor="var(--moss-deep)" stopOpacity="0"/>
                  <stop offset="100%" stopColor="var(--moss-deep)" stopOpacity="0.32"/>
                </radialGradient>
              </defs>

              {/* Ocean fill — the d3 sphere path is the front disc circle. */}
              {sphereD && <path d={sphereD} fill="url(#globe-ocean)"/>}

              {/* Lat/lon graticule — back behind the land. */}
              {gratD && <path d={gratD}
                fill="none" stroke="var(--moss-deep)" strokeWidth="0.5" opacity="0.16"/>}

              {/* Coastlines from world-atlas. d3 already culls back-facing geometry. */}
              {landD && <path d={landD}
                fill="none" stroke="var(--moss-deep)" strokeWidth="0.9"
                strokeLinejoin="round" strokeLinecap="round" opacity="0.78"/>}

              {/* Soft rim shading + sphere outline */}
              {sphereD && <path d={sphereD} fill="url(#globe-rim)"/>}
              {sphereD && <path d={sphereD}
                fill="none" stroke="var(--moss-deep)" strokeWidth="1" opacity="0.55"/>}

              {/* HQ markers — one <g> per cluster. Hovering any dot in the
                  cluster reveals every stacked label, so co-located HQs
                  (Melbourne, Johannesburg metro) are all reachable. */}
              {clusters.map(cluster => {
                const isHover = hoverId === cluster.id;
                const pillH = 22;
                const pillGeoms = cluster.labels.map(l => {
                  const pillW = Math.max(72, l.hq.name.length * 6.4 + 32);
                  const pillX = l.side === 'R'
                    ? l.xy[0] + 18
                    : l.xy[0] - 18 - pillW;
                  const pillY = l.anchorY - pillH / 2;
                  const leaderEnd = l.side === 'R' ? pillX : pillX + pillW;
                  return { l, pillW, pillX, pillY, leaderEnd };
                });
                // Always-rendered hit rect spanning every dot + every pill.
                // Without this, the cursor leaves the <g>'s descendants in
                // the gap between dot and pill, mouseLeave fires, the pill
                // un-renders, and the click never lands.
                let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
                cluster.items.forEach(d => {
                  minX = Math.min(minX, d.xy[0] - 14);
                  maxX = Math.max(maxX, d.xy[0] + 14);
                  minY = Math.min(minY, d.xy[1] - 14);
                  maxY = Math.max(maxY, d.xy[1] + 14);
                });
                pillGeoms.forEach(({pillX, pillY, pillW}) => {
                  minX = Math.min(minX, pillX - 4);
                  maxX = Math.max(maxX, pillX + pillW + 4);
                  minY = Math.min(minY, pillY - 4);
                  maxY = Math.max(maxY, pillY + pillH + 4);
                });
                return (
                  <g key={cluster.id}
                    style={{cursor:'pointer'}}
                    onMouseEnter={()=> {
                      if (dragging) return;
                      cancelClearHover();
                      setHover(cluster.id);
                    }}
                    onMouseLeave={()=> scheduleClearHover(cluster.id)}>
                    {/* Hit area covering dot(s) + pill area(s). Always
                        rendered so the <g> boundary is stable across
                        hover transitions. */}
                    <rect x={minX} y={minY}
                      width={maxX - minX} height={maxY - minY}
                      fill="transparent"/>
                    {/* Dots (one per HQ) — always visible. A faint pulse ring
                        sits behind each dot when the cluster isn't hovered;
                        the hover halo below replaces it on hover. */}
                    {cluster.items.map(d => (
                      <React.Fragment key={d.hq.id}>
                        {!isHover && (
                          <circle className="hq-pulse"
                            cx={d.xy[0]} cy={d.xy[1]}
                            fill="var(--moss)"
                            style={{pointerEvents:'none'}}/>
                        )}
                        <circle
                          cx={d.xy[0]} cy={d.xy[1]}
                          r={isHover ? 4.6 : 3.6}
                          fill={isHover ? 'var(--moss-deep)' : 'var(--moss)'}/>
                      </React.Fragment>
                    ))}
                    {/* Halo — on hover, around centroid */}
                    {isHover && (
                      <circle cx={cluster.cx} cy={cluster.cy} r={9}
                        fill="var(--moss)" opacity={0.28}/>
                    )}
                    {/* Stacked labels — all visible on hover, each its
                        own click target for that specific HQ. */}
                    {isHover && pillGeoms.map(({l, pillW, pillX, pillY, leaderEnd}) => (
                      <g key={l.hq.id}
                        onClick={()=>{
                          if (justDraggedRef.current) {
                            justDraggedRef.current = false;
                            return;
                          }
                          setRoute({page:'company', id:l.hq.id});
                        }}>
                        <line x1={l.xy[0]} y1={l.xy[1]} x2={leaderEnd} y2={l.anchorY}
                          stroke="var(--moss)" strokeWidth="0.9" opacity={0.95}/>
                        <rect x={pillX} y={pillY} width={pillW} height={pillH}
                          rx={pillH/2} ry={pillH/2}
                          fill="var(--moss-deep)"
                          stroke="var(--moss-deep)"
                          strokeWidth={1.2}/>
                        <text x={pillX + 12} y={pillY + 15}
                          fill="#FBFAF4"
                          fontSize="11" fontWeight="500"
                          style={{userSelect:'none',pointerEvents:'none'}}>
                          {l.hq.name}
                        </text>
                        <text x={pillX + pillW - 11} y={pillY + 15}
                          textAnchor="middle"
                          fill="#FBFAF4"
                          fontSize="13" fontWeight="700"
                          style={{userSelect:'none',pointerEvents:'none'}}>
                          ›
                        </text>
                        <title>{l.hq.name} · {l.hq.city} — open scorecard</title>
                      </g>
                    ))}
                    {/* Cluster title for accessibility */}
                    <title>{cluster.items.map(d => d.hq.name).join(' · ')}</title>
                  </g>
                );
              })}
          </svg>
          </div>
        </div>
      </div>
    </section>
  );
};
window.Globe = Globe;
