// op-map.jsx — the flat network atlas (ValidatorAtlas), plotted from the real node
// file. Renders on a 1000×500 viewBox; scales to container.
//
// WorldMap below has never been rendered: no call site at any point in this repo's
// history, and ValidatorAtlas is what the hub draws. It is kept here only so its
// MAP_NODES table, which is real deployment data, is not lost with it. The
// component itself is queued for removal in a follow-up, once that table has a
// live source or a home of its own.

// Hermes production fleet, positioned directly on the 1000x500 map rather than as
// lon/lat. Real deployment cities; the seven not in OPTIMUM_PRESENCE are San
// Francisco, New York, London, Frankfurt, Johannesburg, Singapore and Seoul.
// TODO(live-data): hardcoded, and in screen coordinates, so it cannot be checked
// against anything. Needs a live deployment feed, and lon/lat rather than x/y.
const MAP_NODES = [
  // [x, y, id, region]
  [180, 200, 'P', 'San Francisco'],
  [240, 195, 'B', 'New York'],
  [220, 250, 'M', 'Mexico City'],
  [340, 380, 'D', 'São Paulo'],
  [310, 430, 'Q', 'Buenos Aires'],
  [515, 175, 'C', 'London'],
  [545, 195, 'A', 'Frankfurt'],
  [555, 145, 'I', 'Stockholm'],
  [540, 325, 'E', 'Lagos'],
  [585, 250, 'N', 'Cairo'],
  [590, 405, 'J', 'Johannesburg'],
  [690, 250, 'H', 'Mumbai'],
  [770, 335, 'K', 'Singapore'],
  [825, 200, 'G', 'Tokyo'],
  [805, 215, 'L', 'Seoul'],
  [880, 425, 'F', 'Sydney'],
];

// Arc connection pairs (indices into MAP_NODES). Picked to span the globe nicely.
const MAP_ARCS = [
  [1, 5], [5, 6], [6, 13], [13, 12], [12, 11], [11, 10],
  [1, 3], [3, 8], [8, 10], [10, 15], [15, 14], [14, 13],
  [0, 1], [6, 7], [7, 5], [11, 12], [14, 6],
];

// Continent dot-grid: pre-computed list of (x,y) dots that form continent silhouettes.
// We compute this once at import.
function computeContinents() {
  const out = [];
  // Continents are approximated as unions of ellipses on the 1000×500 map.
  const shapes = [
    // North America
    { cx: 200, cy: 180, rx: 120, ry: 70 },
    { cx: 230, cy: 230, rx: 60, ry: 50 },
    // Central / South America
    { cx: 260, cy: 280, rx: 35, ry: 35 },
    { cx: 320, cy: 380, rx: 55, ry: 90 },
    { cx: 320, cy: 440, rx: 28, ry: 30 },
    // Europe
    { cx: 540, cy: 180, rx: 70, ry: 50 },
    // Africa
    { cx: 555, cy: 320, rx: 75, ry: 110 },
    // Asia
    { cx: 730, cy: 200, rx: 180, ry: 80 },
    { cx: 700, cy: 260, rx: 60, ry: 50 },
    { cx: 770, cy: 320, rx: 30, ry: 30 },
    // Oceania
    { cx: 870, cy: 420, rx: 60, ry: 38 },
    // Greenland / Iceland hint
    { cx: 430, cy: 110, rx: 35, ry: 25 },
  ];
  const step = 11;
  for (let y = 60; y <= 460; y += step) {
    for (let x = 60; x <= 950; x += step) {
      for (const s of shapes) {
        const dx = (x - s.cx) / s.rx;
        const dy = (y - s.cy) / s.ry;
        if (dx * dx + dy * dy <= 1) {
          // jitter slightly for organic feel
          const jx = (Math.random() - 0.5) * 1.5;
          const jy = (Math.random() - 0.5) * 1.5;
          out.push([x + jx, y + jy]);
          break;
        }
      }
    }
  }
  return out;
}
const CONTINENT_DOTS = computeContinents();


// Build a curved path between two points (arc that bulges away from center).
function arcPath(x1, y1, x2, y2, bulge = 0.18) {
  const mx = (x1 + x2) / 2;
  const my = (y1 + y2) / 2;
  const dx = x2 - x1, dy = y2 - y1;
  const dist = Math.hypot(dx, dy);
  // perpendicular offset, biased upward
  const nx = -dy / (dist || 1);
  const ny = dx / (dist || 1);
  const off = dist * bulge;
  // bias the arc to bulge upward (negative y) regardless of order
  const cx = mx + nx * off * (ny < 0 ? 1 : -1);
  const cy = my + ny * off * (ny < 0 ? 1 : -1);
  return `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
}

function WorldMap({
  height = 540,
  showOverlays = true,
  activeId = 'A',
  onPick,
  variant = 'a',
}) {
  // Stagger the packet animations
  const packetDuration = 4.5;
  return (
    <div style={{ position: 'relative', width: '100%', height, overflow: 'hidden' }}>
      <svg
        viewBox="0 0 1000 500"
        preserveAspectRatio="xMidYMid slice"
        width="100%" height="100%"
        style={{ display: 'block', filter: 'drop-shadow(0 20px 30px rgba(40,44,52,0.10))' }}
      >
        <defs>
          <radialGradient id="mapGlow" cx="50%" cy="40%" r="80%">
            <stop offset="0%" stopColor="rgba(81,177,254,0.06)" />
            <stop offset="100%" stopColor="rgba(81,177,254,0)" />
          </radialGradient>
          <linearGradient id="arcStroke" x1="0" x2="1">
            <stop offset="0%" stopColor="rgba(40,44,52,0.05)" />
            <stop offset="50%" stopColor="rgba(40,44,52,0.45)" />
            <stop offset="100%" stopColor="rgba(40,44,52,0.05)" />
          </linearGradient>
          <radialGradient id="packetG" cx="50%" cy="50%" r="50%">
            <stop offset="0%" stopColor="#51B1FE" stopOpacity="1" />
            <stop offset="50%" stopColor="#51B1FE" stopOpacity="0.7" />
            <stop offset="100%" stopColor="#51B1FE" stopOpacity="0" />
          </radialGradient>
          <radialGradient id="nodeGlow" cx="50%" cy="50%" r="50%">
            <stop offset="0%" stopColor="rgba(81,177,254,0.35)" />
            <stop offset="100%" stopColor="rgba(81,177,254,0)" />
          </radialGradient>
        </defs>

        {/* ambient glow */}
        <rect x="0" y="0" width="1000" height="500" fill="url(#mapGlow)" />

        {/* graticule (very subtle) */}
        <g stroke="rgba(40,44,52,0.05)" strokeWidth="0.5" fill="none">
          {Array.from({ length: 6 }).map((_, i) => (
            <line key={'h'+i} x1="0" y1={50 + i * 80} x2="1000" y2={50 + i * 80} />
          ))}
          {Array.from({ length: 10 }).map((_, i) => (
            <line key={'v'+i} x1={100 + i * 100} y1="0" x2={100 + i * 100} y2="500" />
          ))}
        </g>

        {/* continent dots */}
        <g fill="rgba(157,176,194,0.55)">
          {CONTINENT_DOTS.map((d, i) => (
            <circle key={i} cx={d[0]} cy={d[1]} r={i % 17 === 0 ? 1.3 : 0.85} />
          ))}
        </g>

        {/* arcs (static) */}
        <g fill="none" stroke="url(#arcStroke)" strokeWidth="0.7">
          {MAP_ARCS.map((p, i) => {
            const a = MAP_NODES[p[0]], b = MAP_NODES[p[1]];
            return <path key={i} d={arcPath(a[0], a[1], b[0], b[1])} />;
          })}
        </g>

        {/* animated packets traveling along arcs */}
        <g>
          {MAP_ARCS.map((p, i) => {
            const a = MAP_NODES[p[0]], b = MAP_NODES[p[1]];
            const d = arcPath(a[0], a[1], b[0], b[1]);
            const delay = (i * 0.45) % packetDuration;
            return (
              <g key={'pk'+i}>
                <circle r="3.4" fill="url(#packetG)">
                  <animateMotion dur={packetDuration + 's'} begin={-delay + 's'} repeatCount="indefinite" path={d} />
                </circle>
              </g>
            );
          })}
        </g>

        {/* node pulses */}
        <g>
          {MAP_NODES.map((n, i) => {
            const isActive = n[2] === activeId;
            return (
              <g key={n[2]} style={{ cursor: onPick ? 'pointer' : 'default' }}
                 onClick={() => onPick && onPick(n[2])}>
                <circle cx={n[0]} cy={n[1]} r={isActive ? 18 : 14} fill="url(#nodeGlow)" />
                <circle cx={n[0]} cy={n[1]} r={isActive ? 5 : 3.2} fill={isActive ? '#51B1FE' : '#282C34'}>
                  <animate attributeName="r"
                    values={isActive ? '5;7;5' : '3.2;4.2;3.2'}
                    dur="2.6s" repeatCount="indefinite"
                    begin={(i * 0.18) + 's'} />
                </circle>
                <circle cx={n[0]} cy={n[1]} r={isActive ? 8 : 6} fill="none"
                        stroke="rgba(81,177,254,0.65)" strokeWidth="0.9">
                  <animate attributeName="r"
                    values={isActive ? '8;22;8' : '6;16;6'} dur="3.2s"
                    repeatCount="indefinite" begin={(i * 0.21) + 's'} />
                  <animate attributeName="opacity"
                    values="0.7;0;0.7" dur="3.2s"
                    repeatCount="indefinite" begin={(i * 0.21) + 's'} />
                </circle>
                {isActive && (
                  <text x={n[0]} y={n[1] - 16} fontFamily="Geist Mono, monospace"
                        fontSize="11" fill="#282C34" fontWeight="500" textAnchor="middle">Node-{n[2]} · {n[3]}</text>
                )}
              </g>
            );
          })}
        </g>

        {/* compass / scale label */}
        <g fontFamily="Geist Mono, monospace" fontSize="9" fill="rgba(40,44,52,0.40)" letterSpacing="2">
          <text x="24" y="488">OPTIMUM · NETWORK ATLAS · LIVE</text>
          <text x="976" y="488" textAnchor="end">{variant === 'a' ? '14 REGIONS' : '247 NODES'}</text>
        </g>
      </svg>
    </div>
  );
}

// Connect each node to its 2 nearest neighbours (dedup pairs) → a natural mesh.
function buildMeshArcs(nodes) {
  const seen = new Set();
  const arcs = [];
  nodes.forEach((n, i) => {
    const near = nodes
      .map((m, j) => ({ j, d: (m.x - n.x) ** 2 + (m.y - n.y) ** 2 }))
      .filter(o => o.j !== i)
      .sort((a, b) => a.d - b.d)
      .slice(0, 2);
    near.forEach(o => {
      const key = i < o.j ? i + '-' + o.j : o.j + '-' + i;
      if (!seen.has(key)) { seen.add(key); arcs.push([nodes[i], nodes[o.j]]); }
    });
  });
  return arcs;
}

// Flat network atlas — the production node fleet plotted on a world map, one
// mark per site, hover for its city and machine count. Countries that run
// nodes are lifted a tone and carry their own total on hover. Theme-aware.
// Real Natural-Earth 50m country polygons, projected equirectangularly. We use
// the 50m tier (not 110m) so small territories render — e.g. Spain's Canary
// Islands, and other island states the coarse 110m mesh drops entirely.
let ATLAS_COUNTRIES = null;   // [{ name, token, d }]
let ATLAS_PROMISE = null;
const ATLAS_NE_URL = 'https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/50m/cultural/ne_50m_admin_0_countries.json';
// Natural Earth marks a few sovereign states ISO_A2="-99"; map the ones we plot by admin name.
const ATLAS_ADMIN_TO_TOKEN = { France: 'FR', Norway: 'NO' };
function atlasToken(props) {
  const iso = props && props.ISO_A2;
  if (iso && iso !== '-99') return String(iso).toUpperCase();
  return (props && ATLAS_ADMIN_TO_TOKEN[props.ADMIN]) || null;
}
function loadAtlasCountries() {
  if (ATLAS_COUNTRIES) return Promise.resolve(ATLAS_COUNTRIES);
  if (ATLAS_PROMISE) return ATLAS_PROMISE;
  if (typeof fetch !== 'function') return Promise.resolve([]);
  ATLAS_PROMISE = fetch(ATLAS_NE_URL)
    .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
    .then(geo => {
      const project = ([lon, lat]) => ((lon + 180) / 360 * 1000).toFixed(1) + ',' + ((90 - lat) / 180 * 500).toFixed(1);
      const ringPath = (outer) => 'M' + outer.map(project).join('L') + 'Z';
      const ringBbox = (outer) => {
        let x0 = 180, y0 = 90, x1 = -180, y1 = -90;
        for (const [lon, lat] of outer) { if (lon < x0) x0 = lon; if (lon > x1) x1 = lon; if (lat < y0) y0 = lat; if (lat > y1) y1 = lat; }
        return [x0, y0, x1, y1];
      };
      // Crimea + Sevastopol are baked into Russia's polygon in this dataset, but
      // are internationally recognized as part of Ukraine. We move that one
      // sub-polygon from Russia to Ukraine so the map follows the recognized
      // border. (Abkhazia / South Ossetia already sit inside Georgia here, which
      // matches the international consensus, so they need no change.)
      const isCrimeaRing = (b) => b[0] >= 32 && b[2] <= 37 && b[1] >= 43.5 && b[3] <= 47 && (b[2] - b[0]) < 8 && (b[3] - b[1]) < 5;
      const out = [];
      let crimeaPath = '';
      for (const feat of (geo.features || [])) {
        const g = feat.geometry; if (!g) continue;
        const props = feat.properties || {};
        const admin = props.ADMIN || props.NAME || '—';
        const polys = g.type === 'Polygon' ? [g.coordinates] : g.type === 'MultiPolygon' ? g.coordinates : [];
        let d = '';
        for (const poly of polys) {
          const outer = poly[0];
          if (!outer || outer.length <= 2) continue;
          if (admin === 'Russia' && isCrimeaRing(ringBbox(outer))) { crimeaPath += ringPath(outer); continue; }
          d += ringPath(outer);
        }
        if (d) out.push({ name: admin, token: atlasToken(props), d });
      }
      if (crimeaPath) { const uk = out.find(f => f.name === 'Ukraine'); if (uk) uk.d += crimeaPath; }
      ATLAS_COUNTRIES = out;
      return out;
    })
    .catch(err => { if (typeof console !== 'undefined') console.warn('[ATLAS] world map load failed:', err); ATLAS_COUNTRIES = []; return []; });
  return ATLAS_PROMISE;
}
// Prefetch the geometry during idle time (the 50m file is ~1MB gzipped) so it's
// cached before the atlas opens, without competing with the dashboard's first paint.
if (typeof window !== 'undefined' && typeof fetch === 'function') {
  const idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 1200); };
  idle(function () { loadAtlasCountries(); });
}

// Equirectangular projection onto the 1000×500 viewBox — same math the country
// polygons use, so centroid marks land exactly on their country.
function atlasProject(lon, lat) {
  return { x: (lon + 180) / 360 * 1000, y: (90 - lat) / 180 * 500 };
}
// Base width (viewBox units) of the Optimum node mark. Counter-scaled by zoom
// so it holds a constant on-screen size regardless of zoom level.
const ATLAS_NODE_W = 9;

// Production node locations — the same snapshot the globe plots, so both views
// agree on where the fleet runs.
const ATLAS_NODES_URL = 'dashboard/data/network-nodes.json';
let ATLAS_NODES = null;
let ATLAS_NODES_PROMISE = null;
// TODO(live-data): network-nodes.json is a hand-run `gcloud compute instances
// list` snapshot, so the markers age silently. Needs a live node feed; the
// closest first-party source (gateways/active) carries no geography.
function loadNetworkNodes() {
  if (ATLAS_NODES) return Promise.resolve(ATLAS_NODES);
  if (ATLAS_NODES_PROMISE) return ATLAS_NODES_PROMISE;
  if (typeof fetch !== 'function') return Promise.resolve([]);
  ATLAS_NODES_PROMISE = fetch(ATLAS_NODES_URL)
    .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
    .then(d => { ATLAS_NODES = d.locations || []; return ATLAS_NODES; })
    .catch(err => { console.warn('[ATLAS] node locations load failed:', err); return []; });
  return ATLAS_NODES_PROMISE;
}

function ValidatorAtlas({ height = 560, dark = false, caption, hint }) {
  const svgRef = React.useRef(null);
  const wrapRef = React.useRef(null);
  const dragRef = React.useRef(null);
  const zoomRef = React.useRef(1);
  const panRef = React.useRef({ x: 0, y: 0 });
  const [zoom, setZoom] = React.useState(1);
  const [pan, setPan] = React.useState({ x: 0, y: 0 });
  const [countries, setCountries] = React.useState(ATLAS_COUNTRIES || []);
  const [nodes, setNodes] = React.useState(ATLAS_NODES || []);
  const [hover, setHover] = React.useState(null);       // { name, count } — country identity
  const [hoverNode, setHoverNode] = React.useState(null);
  const [tip, setTip] = React.useState({ x: 0, y: 0 }); // cursor pos (container space)
  React.useEffect(() => {
    if (countries.length) return undefined;
    let alive = true;
    loadAtlasCountries().then(c => { if (alive && c && c.length) setCountries(c); });
    return () => { alive = false; };
  }, [countries.length]);
  React.useEffect(() => {
    if (nodes.length) return undefined;
    let alive = true;
    loadNetworkNodes().then(n => { if (alive && n && n.length) setNodes(n); });
    return () => { alive = false; };
  }, [nodes.length]);

  const clampPan = (p, z) => ({ x: Math.min(0, Math.max(-1000 * (z - 1), p.x)), y: Math.min(0, Math.max(-500 * (z - 1), p.y)) });
  const apply = (z, p) => { zoomRef.current = z; panRef.current = p; setZoom(z); setPan(p); };
  const toVB = (clientX, clientY) => {
    const svg = svgRef.current; if (!svg || !svg.getScreenCTM) return { x: 500, y: 250 };
    const m = svg.getScreenCTM(); if (!m) return { x: 500, y: 250 };
    const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
    const v = pt.matrixTransform(m.inverse());
    return { x: v.x, y: v.y };
  };
  const zoomAt = (vb, factor) => {
    const z = zoomRef.current, p = panRef.current;
    const z2 = Math.min(7, Math.max(1, z * factor));
    if (z2 === z) return;
    const wx = (vb.x - p.x) / z, wy = (vb.y - p.y) / z;
    apply(z2, clampPan({ x: vb.x - wx * z2, y: vb.y - wy * z2 }, z2));
  };
  React.useEffect(() => {
    const svg = svgRef.current; if (!svg) return undefined;
    const onWheel = (e) => { e.preventDefault(); zoomAt(toVB(e.clientX, e.clientY), e.deltaY < 0 ? 1.18 : 1 / 1.18); };
    svg.addEventListener('wheel', onWheel, { passive: false });
    return () => svg.removeEventListener('wheel', onWheel);
  }, []);
  const onPointerDown = (e) => { if (zoomRef.current <= 1) return; dragRef.current = toVB(e.clientX, e.clientY); if (e.currentTarget.setPointerCapture) e.currentTarget.setPointerCapture(e.pointerId); };
  const onPointerMove = (e) => {
    if (!dragRef.current) return;
    const cur = toVB(e.clientX, e.clientY);
    apply(zoomRef.current, clampPan({ x: panRef.current.x + (cur.x - dragRef.current.x), y: panRef.current.y + (cur.y - dragRef.current.y) }, zoomRef.current));
    dragRef.current = cur;
  };
  const onPointerUp = () => { dragRef.current = null; };

  const gridStroke = dark ? 'rgba(255,255,255,0.05)' : 'rgba(40,44,52,0.05)';
  const landFill = dark ? '#2A2F3A' : '#E7ECF2';
  // Marks the countries that run nodes. Flat rather than a ramp: four hubs hold
  // 46 machines and the other eleven countries hold two, so a proportional scale
  // would collapse to two visible values.
  const landActive = dark ? '#39414F' : '#D3DEEA';
  const borderCol = dark ? 'rgba(15,18,24,0.55)' : 'rgba(255,255,255,0.6)';
  // Inverted against the map so the coin keeps its contrast in either theme.
  const nodeCoin = dark ? '#F4F6F8' : '#0B0E14';
  const nodeRing = dark ? 'rgba(11,14,20,0.30)' : 'rgba(255,255,255,0.30)';
  const nodeMarkSrc = (typeof window !== 'undefined' && window.optimumMarkDataUri)
    ? window.optimumMarkDataUri(dark ? '#282C34' : '#FFFFFF') : '';
  // The tip sits on --op-card, not on the map, so its icon inverts the other way.
  const tipMarkSrc = (typeof window !== 'undefined' && window.optimumMarkDataUri)
    ? window.optimumMarkDataUri(dark ? '#FFFFFF' : '#282C34') : '';

  // Biggest last, so the major hubs paint over the two-machine sites near them.
  const nodeMarks = React.useMemo(() => nodes
    .map(n => ({ ...n, ...atlasProject(n.lon, n.lat) }))
    .sort((a, b) => a.count - b.count), [nodes]);

  const onNode = (e, n) => {
    const rect = wrapRef.current && wrapRef.current.getBoundingClientRect();
    if (rect) setTip({ x: e.clientX - rect.left, y: e.clientY - rect.top });
    setHoverNode(prev => (prev && prev.id === n.id) ? prev : n);
  };

  // Nodes per country, keyed by the ISO-2 token the polygons carry.
  const countryNodes = React.useMemo(() => nodes.reduce((acc, n) => {
    if (n.country) acc[n.country] = (acc[n.country] || 0) + (n.count || 0);
    return acc;
  }, {}), [nodes]);

  const onCountry = (e, c, count) => {
    const rect = wrapRef.current && wrapRef.current.getBoundingClientRect();
    if (rect) setTip({ x: e.clientX - rect.left, y: e.clientY - rect.top });
    setHover(prev => (prev && prev.name === c.name) ? prev : { name: c.name, count });
  };

  const hoverName = hover ? hover.name : null;
  // Only countries that run nodes respond — the rest have no total to report.
  // Memoized so cursor moves (tip position) don't re-render 170+ paths.
  const paths = React.useMemo(() => countries.map((c, i) => {
    const count = countryNodes[c.token] || 0;
    if (!count) {
      return <path key={i} d={c.d} fill={landFill} stroke={borderCol}
        strokeWidth={(0.4 / zoom).toFixed(2)} strokeLinejoin="round" />;
    }
    const isHover = c.name === hoverName;
    return (
      <path key={i} d={c.d}
        fill={isHover ? '#64E27F' : landActive}
        stroke={isHover ? '#2FB765' : borderCol}
        strokeWidth={((isHover ? 0.9 : 0.4) / zoom).toFixed(2)}
        strokeLinejoin="round"
        style={{ cursor: 'pointer', transition: 'fill .1s ease' }}
        onMouseEnter={(e) => onCountry(e, c, count)}
        onMouseMove={(e) => onCountry(e, c, count)}
        onMouseLeave={() => setHover(null)}
        /* Tap target for touch, where there is no hover to trigger the tip. */
        onClick={(e) => onCountry(e, c, count)} />
    );
  }), [countries, countryNodes, hoverName, zoom, dark]);

  return (
    <div ref={wrapRef} style={{ position: 'relative', width: '100%', height, flex: '1 1 auto', minHeight: 0, overflow: 'hidden' }}>
      <svg ref={svgRef} viewBox="0 0 1000 500" preserveAspectRatio="xMidYMid meet" width="100%" height="100%"
        style={{ display: 'block', touchAction: 'none', cursor: zoom > 1 ? 'grab' : 'default' }}
        onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={() => { onPointerUp(); setHover(null); setHoverNode(null); }}>
        <g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
          {/* graticule */}
          <g stroke={gridStroke} strokeWidth="0.5" fill="none">
            {Array.from({ length: 6 }).map((_, i) => <line key={'h' + i} x1="0" y1={50 + i * 80} x2="1000" y2={50 + i * 80} />)}
            {Array.from({ length: 10 }).map((_, i) => <line key={'v' + i} x1={100 + i * 100} y1="0" x2={100 + i * 100} y2="500" />)}
          </g>
          {/* dot-grid fallback while country geometry loads */}
          {countries.length === 0 && (
            <g fill={dark ? 'rgba(120,136,152,0.35)' : 'rgba(157,176,194,0.5)'}>
              {CONTINENT_DOTS.map((d, i) => <circle key={i} cx={d[0]} cy={d[1]} r={i % 17 === 0 ? 1.3 : 0.85} />)}
            </g>
          )}
          {paths}
          {/* Production nodes — the atlas's only data layer. Counter-scaled by
              zoom so they hold a constant on-screen size and separate naturally
              as you zoom into dense regions like Europe. */}
          {nodeMarkSrc && nodeMarks.map((n, i) => {
            const w = ATLAS_NODE_W / zoom;
            const h = w * (144 / 150);
            const r = (w * 0.72).toFixed(2);
            const sw = (0.6 / zoom).toFixed(2);
            const on = hoverNode && hoverNode.id === n.id;
            // Stagger the gentle pulse per mark → a soft twinkle across the atlas.
            // Negative begin starts each mark mid-cycle so nothing pops in on open.
            const begin = (-(i * 0.24)).toFixed(2) + 's';
            return (
              <g key={n.id} transform={`translate(${n.x.toFixed(1)} ${n.y.toFixed(1)})`}
                style={{ cursor: 'pointer' }}
                onMouseEnter={(e) => onNode(e, n)}
                onMouseMove={(e) => onNode(e, n)}
                onMouseLeave={() => setHoverNode(null)}>
                {/* inner group carries no base transform, so the scale pulse pivots on the mark's centre */}
                <g>
                  {!on && <animateTransform attributeName="transform" type="scale" values="0.9;1.08;0.9" dur="3.6s" begin={begin} repeatCount="indefinite" />}
                  {!on && <animate attributeName="opacity" values="0.85;1;0.85" dur="3.6s" begin={begin} repeatCount="indefinite" />}
                  <circle cx="0" cy="0" r={r} fill={nodeCoin}
                    stroke={on ? '#51B1FE' : nodeRing} strokeWidth={on ? (sw * 1.7).toFixed(2) : sw} />
                  <image href={nodeMarkSrc} x={-w / 2} y={-h / 2} width={w} height={h} preserveAspectRatio="xMidYMid meet" />
                </g>
              </g>
            );
          })}
        </g>
      </svg>

      {/* A node overlaps its own country, so its tip wins. */}
      {(hoverNode || hover) && (
        <div className="op-atlas-tip" style={{ left: tip.x, top: tip.y }}>
          {tipMarkSrc ? <img src={tipMarkSrc} alt="" width="15" height="14" /> : null}
          <span className="c">{hoverNode ? hoverNode.city : hover.name}</span>
          <b>{(hoverNode || hover).count.toLocaleString('en-US')}</b>
          <span className="u">{(hoverNode || hover).count === 1 ? 'node' : 'nodes'}</span>
        </div>
      )}

      {(caption || hint) && (
        <div className="op-atlas-caption">
          {caption ? <div className="cap-1">{caption}</div> : null}
          {hint ? <div className="cap-2">{hint}</div> : null}
        </div>
      )}

      <div className="op-atlas-zoom" role="group" aria-label="Zoom">
        <button type="button" onClick={() => zoomAt({ x: 500, y: 250 }, 1.5)} aria-label="Zoom in">+</button>
        <button type="button" onClick={() => zoomAt({ x: 500, y: 250 }, 1 / 1.5)} aria-label="Zoom out">−</button>
        <button type="button" className="reset" onClick={() => apply(1, { x: 0, y: 0 })} aria-label="Reset view" title="Reset view">↺</button>
      </div>
    </div>
  );
}

Object.assign(window, { WorldMap, ValidatorAtlas, MAP_NODES, loadNetworkNodes });
