// Minimal light-theme app — accurate coastlines, one sidebar, arc on select
(function(){
try {

const { useState, useEffect, useRef, useMemo } = React;
const { rotate, project, greatCircle, midpoint, arcPath, DEG } = window.GlobeMath;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "arcStyle": "curved",
  "accent": "#c14c2c",
  "labelDensity": "selected",
  "showStats": true
}/*EDITMODE-END*/;

function fmtDur(min) {
  const h = Math.floor(min / 60), m = min % 60;
  return `${h}h ${String(m).padStart(2,'0')}m`;
}
function fmtMi(nm) { return Math.round(nm * 1.15078).toLocaleString(); }
function fmtDate(iso) {
  return new Date(iso + 'T00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
const MULTIWORD_CITY_PREFIX = new Set([
  'new','los','las','san','santa','santo','st','st.','saint','hong','rio','são','sao',
  'port','fort','mexico','buenos','ho','kuala','abu','tel','cape','puerto','el','la',
  'addis','dar','bandar','phnom','siem','ulaan','ulan','tel-aviv','des','nueva','isla'
]);
function cityOf(name) {
  if (!name) return '';
  const stripped = String(name)
    .replace(/\s+(International|Intl\.?|Regional|Municipal|Metropolitan|Airport|Airfield|Airpark|Field)\b.*$/i, '')
    .trim();
  const parts = stripped.split(/\s+/);
  if (parts.length <= 1) return stripped;
  const take = MULTIWORD_CITY_PREFIX.has(parts[0].toLowerCase()) ? 2 : 1;
  return parts.slice(0, take).join(' ');
}
function fmtShortDate(iso) {
  return new Date(iso + 'T00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

function shortestDelta(from, to) {
  let d = to - from;
  while (d > 180) d -= 360;
  while (d < -180) d += 360;
  return d;
}

/* ============ Globe ============ */
function Globe({ rotation, setRotation, zoom, setZoom, width, height, baseSize, selected, hover, accent, arcStyle, labelDensity, animKey, demoArcs, demoMode }) {
  const cx = width/2, cy = height/2;
  const r = baseSize * 0.44 * zoom;
  const ref = useRef(null);
  const dragRef = useRef(null);
  const rafRef = useRef(null);

  // Auto-rotate + zoom on selection
  useEffect(() => {
    if (!selected) {
      // Ease back to default zoom when deselected
      const startTime = performance.now();
      const fromZ = zoom;
      const fromLon = rotation[0], fromLat = rotation[1];
      cancelAnimationFrame(rafRef.current);
      const tick = () => {
        const el = performance.now() - startTime;
        const p = Math.min(1, el / 700);
        const e = 1 - Math.pow(1 - p, 3);
        setZoom(fromZ + (1 - fromZ) * e);
        setRotation([fromLon, fromLat + (0 - fromLat) * e]);
        if (p < 1) rafRef.current = requestAnimationFrame(tick);
      };
      rafRef.current = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(rafRef.current);
    }
    const o = window.AIRPORTS[selected.o];
    const d = window.AIRPORTS[selected.d];
    const mid = midpoint(o[0], o[1], d[0], d[1]);
    // Clamp center latitude away from the poles so polar-proximal routes
    // (DFW↔NRT, etc.) don't produce a pole-centered projection — that view
    // puts the land-ring around the pole and the closed-polygon rendering
    // fills the Arctic basin as if it were land.
    const avgLat = (o[0] + d[0]) / 2;
    const centerLat = Math.abs(mid[0]) > 55 ? avgLat : mid[0];

    // Compute target zoom based on the great-circle angular span between
    // the two airports. Short hops (LAX→SFO ≈ 5°) zoom in tight; transpacific
    // routes (~110°) barely zoom at all.
    const DEG2 = Math.PI / 180;
    const p1 = [Math.cos(o[0]*DEG2)*Math.cos(o[1]*DEG2), Math.cos(o[0]*DEG2)*Math.sin(o[1]*DEG2), Math.sin(o[0]*DEG2)];
    const p2 = [Math.cos(d[0]*DEG2)*Math.cos(d[1]*DEG2), Math.cos(d[0]*DEG2)*Math.sin(d[1]*DEG2), Math.sin(d[0]*DEG2)];
    const dot = Math.max(-1, Math.min(1, p1[0]*p2[0]+p1[1]*p2[1]+p1[2]*p2[2]));
    const angDeg = Math.acos(dot) / DEG2;
    // Target: fit the arc within ~55% of the viewport radius.
    // Fit angle (half-span) for zoom=1 is ~65° visible-from-center.
    // zoom = fitAngle / max(arcHalfSpan, minAngle)
    const half = Math.max(angDeg / 2, 4);
    let targetZoom = 55 / half;
    targetZoom = Math.max(1, Math.min(3.2, targetZoom));

    const startTime = performance.now();
    const fromLon = rotation[0], fromLat = rotation[1];
    const fromZoom = zoom;
    const toLon = -mid[1], toLat = -centerLat;
    cancelAnimationFrame(rafRef.current);
    const tick = () => {
      const el = performance.now() - startTime;
      const p = Math.min(1, el / 1200);
      const e = 1 - Math.pow(1 - p, 3);
      setRotation([fromLon + shortestDelta(fromLon, toLon) * e, fromLat + shortestDelta(fromLat, toLat) * e]);
      setZoom(fromZoom + (targetZoom - fromZoom) * e);
      if (p < 1) rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [selected?.id, animKey]);

  const onDown = (e) => {
    cancelAnimationFrame(rafRef.current);
    dragRef.current = { x: e.clientX, y: e.clientY, rot: [...rotation] };
    ref.current.setPointerCapture(e.pointerId);
  };
  const onMove = (e) => {
    if (!dragRef.current) return;
    const dx = e.clientX - dragRef.current.x;
    const dy = e.clientY - dragRef.current.y;
    const lat = Math.max(-85, Math.min(85, dragRef.current.rot[1] - dy * 0.3));
    setRotation([dragRef.current.rot[0] + dx * 0.3, lat]);
  };
  const onUp = (e) => { dragRef.current = null; try { ref.current.releasePointerCapture(e.pointerId); } catch{} };

  // Land paths — build each visible segment with interpolated horizon crossings,
  // then close it by walking the limb arc in the direction that keeps the
  // polygon's interior on the visible side.
  const landPaths = useMemo(() => {
    const out = [];
    const crossing = (a, b) => {
      const t = a.z / (a.z - b.z);
      return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
    };
    const angOf = (p) => Math.atan2(p.y - cy, p.x - cx);
    const limbPt = (theta) => ({ x: cx + Math.cos(theta) * r, y: cy + Math.sin(theta) * r });

    // Each entry in CONTINENTS is a polygon: [outerRing, ...holeRings].
    // We emit a combined path per entry so holes (e.g. the Caspian Sea,
    // which Natural Earth stores as an inner ring of the Eurasia land
    // polygon) cut the land out via fill-rule: evenodd instead of being
    // painted over as opaque black.
    for (const rings of window.CONTINENTS) {
      const entrySubpaths = [];
      for (const ring of rings) {
        const N = ring.length;
        if (N < 3) continue;
        const projected = ring.map(([la, lo]) => project(rotation, la, lo, r, cx, cy));

        // Collect visible segments with entry (first) and exit (last) markers.
        const segs = []; // each: { pts, entryAng, exitAng, wrap }
        let cur = null;
        for (let i = 0; i < N; i++) {
          const p = projected[i];
          const prev = projected[(i - 1 + N) % N];
          const vis = p.z > 0, pvis = prev.z > 0;
          if (vis && !pvis) {
            const c = crossing(prev, p);
            cur = { pts: [c, p], entryAng: angOf(c), exitAng: null };
          } else if (vis && pvis) {
            if (!cur) cur = { pts: [p], entryAng: null, exitAng: null };
            else cur.pts.push(p);
          } else if (!vis && pvis) {
            const c = crossing(prev, p);
            if (cur) {
              cur.pts.push(c);
              cur.exitAng = angOf(c);
              segs.push(cur);
              cur = null;
            }
          }
        }
        if (cur) segs.push(cur);
        if (segs.length === 0) continue;

        // Merge wrap-around segment (first has no entry, last has no exit) — they're
        // actually one contiguous segment because iteration starts mid-visible.
        if (segs.length > 1 && segs[0].entryAng === null && segs[segs.length - 1].exitAng === null) {
          const first = segs.shift();
          const last = segs[segs.length - 1];
          last.pts = last.pts.concat(first.pts);
          last.exitAng = first.exitAng;
        }

        for (const seg of segs) {
          if (seg.pts.length < 2) continue;
          let d = 'M ' + seg.pts.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ');
          if (seg.entryAng !== null && seg.exitAng !== null) {
            // Walk the limb from exit back to entry. Two candidate directions;
            // pick the one that produces the SMALLER polygon area — on an
            // orthographic globe, continent fragments are nearly always smaller
            // than the ocean they sit in, so the smaller closure is the land.
            const a0 = seg.exitAng, a1 = seg.entryAng;
            let dPos = a1 - a0; while (dPos <= 0) dPos += 2 * Math.PI;
            const dNeg = dPos - 2 * Math.PI;
            const makeArcPts = (delta) => {
              const steps = Math.max(6, Math.ceil(Math.abs(delta) / (Math.PI / 72)));
              const out = [];
              for (let k = 1; k <= steps; k++) {
                out.push(limbPt(a0 + delta * (k / steps)));
              }
              return out;
            };
            const polyArea = (pts) => {
              let s = 0;
              for (let k = 0; k < pts.length; k++) {
                const a = pts[k], b = pts[(k + 1) % pts.length];
                s += a.x * b.y - b.x * a.y;
              }
              return Math.abs(s) / 2;
            };
            const arcPosPts = makeArcPts(dPos);
            const arcNegPts = makeArcPts(dNeg);
            const areaPos = polyArea([...seg.pts, ...arcPosPts]);
            const areaNeg = polyArea([...seg.pts, ...arcNegPts]);
            const chosenPts = areaPos < areaNeg ? arcPosPts : arcNegPts;
            for (const lp of chosenPts) d += ` L ${lp.x.toFixed(1)} ${lp.y.toFixed(1)}`;
          }
          d += ' Z';
          entrySubpaths.push(d);
        }
      }
      if (entrySubpaths.length) out.push(entrySubpaths.join(' '));
    }
    return out;
  }, [rotation, r, cx, cy]);

  // Lakes — horizon clip with limb-following closure (not a chord), so edge lakes curve with the globe.
  const lakePaths = useMemo(() => {
    if (!window.LAKES) return [];
    const out = [];
    // Where segment (a visible, b hidden) crosses the horizon — snapped to the actual limb circle.
    const horizonPoint = (a, b) => {
      const t = a.z / (a.z - b.z);
      const x = a.x + (b.x - a.x) * t;
      const y = a.y + (b.y - a.y) * t;
      const ang = Math.atan2(y - cy, x - cx);
      return { x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang), ang };
    };

    for (const ring of window.LAKES) {
      const N = ring.length;
      if (N < 3) continue;
      const P = ring.map(([la, lo]) => project(rotation, la, lo, r, cx, cy));
      if (!P.some(p => p.z > 0)) continue;

      // Start iteration from a hidden index so segments always begin cleanly at a crossing.
      let start = -1;
      for (let i = 0; i < N; i++) if (P[i].z <= 0) { start = i; break; }

      if (start === -1) {
        // Ring entirely on the visible hemisphere — simple closed polygon.
        out.push('M ' + P.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ') + ' Z');
        continue;
      }

      const segs = [];
      let cur = null;
      for (let k = 1; k <= N; k++) {
        const i = (start + k) % N;
        const prevI = (start + k - 1) % N;
        const vis = P[i].z > 0, pvis = P[prevI].z > 0;
        if (vis && !pvis) {
          cur = { enter: horizonPoint(P[prevI], P[i]), pts: [P[i]] };
        } else if (vis && pvis && cur) {
          cur.pts.push(P[i]);
        } else if (!vis && pvis && cur) {
          cur.exit = horizonPoint(P[i], P[prevI]);
          segs.push(cur);
          cur = null;
        }
      }

      for (const seg of segs) {
        if (!seg.enter || !seg.exit) continue;
        let d = `M ${seg.enter.x.toFixed(1)} ${seg.enter.y.toFixed(1)}`;
        for (const p of seg.pts) d += ` L ${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
        d += ` L ${seg.exit.x.toFixed(1)} ${seg.exit.y.toFixed(1)}`;
        // Close with the short arc of the limb (visible horizon edge).
        let da = seg.enter.ang - seg.exit.ang;
        while (da > Math.PI) da -= 2 * Math.PI;
        while (da < -Math.PI) da += 2 * Math.PI;
        const steps = Math.max(2, Math.ceil(Math.abs(da) * 16));
        for (let s = 1; s <= steps; s++) {
          const a = seg.exit.ang + (da * s) / steps;
          d += ` L ${(cx + r * Math.cos(a)).toFixed(1)} ${(cy + r * Math.sin(a)).toFixed(1)}`;
        }
        d += ' Z';
        out.push(d);
      }
    }
    return out;
  }, [rotation, r, cx, cy]);

  // Graticule
  const graticule = useMemo(() => {
    const paths = [];
    for (let lat = -60; lat <= 60; lat += 30) {
      const pts = [];
      for (let lon = -180; lon <= 180; lon += 4) pts.push([lat, lon]);
      paths.push(pts);
    }
    for (let lon = -180; lon < 180; lon += 30) {
      const pts = [];
      for (let lat = -80; lat <= 80; lat += 4) pts.push([lat, lon]);
      paths.push(pts);
    }
    return paths.map(pts => {
      const segs = []; let cur = [];
      for (const [la, lo] of pts) {
        const p = project(rotation, la, lo, r, cx, cy);
        if (p.z > 0) cur.push(p);
        else if (cur.length > 1) { segs.push(cur); cur = []; }
      }
      if (cur.length > 1) segs.push(cur);
      return segs.map(s => 'M ' + s.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' L ')).join(' ');
    }).filter(Boolean);
  }, [rotation, r, cx, cy]);

  const visited = useMemo(() => {
    const s = new Set();
    for (const f of window.FLIGHTS) { s.add(f.o); s.add(f.d); }
    return [...s];
  }, []);

  const active = hover || selected;

  const arcD = useMemo(() => {
    if (!active) return null;
    const o = window.AIRPORTS[active.o];
    const d = window.AIRPORTS[active.d];
    if (arcStyle === 'straight') {
      const pO = project(rotation, o[0], o[1], r, cx, cy);
      const pD = project(rotation, d[0], d[1], r, cx, cy);
      if (pO.z > 0 && pD.z > 0) return `M ${pO.x} ${pO.y} L ${pD.x} ${pD.y}`;
      return null;
    }
    const pts = greatCircle(o[0], o[1], d[0], d[1], 120);
    const segs = []; let cur = [];
    const n = pts.length;
    for (let i = 0; i < n; i++) {
      const t = i / (n - 1);
      const lift = Math.sin(t * Math.PI) * 0.14 + 1.0;
      const p = project(rotation, pts[i][0], pts[i][1], r * lift, cx, cy);
      if (p.z > -0.02) cur.push(p);
      else if (cur.length > 1) { segs.push(cur); cur = []; }
    }
    if (cur.length > 1) segs.push(cur);
    return segs.map(s => 'M ' + s.map(p => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' L ')).join(' ');
  }, [active, rotation, r, cx, cy, arcStyle]);

  const endpoints = useMemo(() => {
    if (!active) return null;
    const o = window.AIRPORTS[active.o];
    const d = window.AIRPORTS[active.d];
    return {
      o: project(rotation, o[0], o[1], r, cx, cy),
      d: project(rotation, d[0], d[1], r, cx, cy),
    };
  }, [active, rotation, r, cx, cy]);

  const demoArcPaths = useMemo(() => {
    if (!demoArcs || !demoArcs.length) return [];
    return demoArcs.map(arc => {
      const [oLat, oLon] = arc.o;
      const [dLat, dLon] = arc.d;
      const pts = greatCircle(oLat, oLon, dLat, dLon, 120);
      const segs = []; let cur = [];
      for (let i = 0; i < pts.length; i++) {
        const t = i / (pts.length - 1);
        const lift = Math.sin(t * Math.PI) * 0.14 + 1.0;
        const p = project(rotation, pts[i][0], pts[i][1], r * lift, cx, cy);
        if (p.z > -0.02) cur.push(p);
        else if (cur.length > 1) { segs.push(cur); cur = []; }
      }
      if (cur.length > 1) segs.push(cur);
      const d = segs.map(s => 'M ' + s.map(p => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' L ')).join(' ');
      const oP = project(rotation, oLat, oLon, r, cx, cy);
      const dP = project(rotation, dLat, dLon, r, cx, cy);
      return { id: arc.id, d, oP, dP, dur: arc.dur };
    }).filter(a => a.d);
  }, [demoArcs, rotation, r, cx, cy]);

  const arcRef = useRef(null);
  const [arcLen, setArcLen] = useState(0);
  useEffect(() => {
    if (!arcRef.current) return;
    try {
      const len = arcRef.current.getTotalLength();
      setArcLen(prev => prev === len ? prev : len);
    } catch {}
  }, [arcD, animKey]);

  const isDashed = arcStyle === 'dashed';
  const animId = `a-${animKey}-${active?.id || ''}`;

  return (
    <svg ref={ref} className="globe" width={width} height={height} viewBox={`0 0 ${width} ${height}`}
      onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
    >
      <defs>
        <filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur stdDeviation="1.5" />
        </filter>
        <clipPath id="globeClip">
          <circle cx={cx} cy={cy} r={r} />
        </clipPath>
      </defs>

      {/* Ocean disc — flat off-white */}
      <circle cx={cx} cy={cy} r={r} fill="var(--ocean)" stroke="rgba(17,17,17,0.12)" strokeWidth="0.75" />

      {/* Graticule */}
      <g stroke="rgba(17,17,17,0.07)" strokeWidth="0.5" fill="none">
        {graticule.map((d, i) => <path key={i} d={d} />)}
      </g>

      {/* Land — clipped to globe disc so any horizon-chord closures stay hidden */}
      <g fill="var(--land)" fillOpacity="0.92" stroke="none" fillRule="evenodd" clipPath="url(#globeClip)">
        {landPaths.map((d, i) => <path key={i} d={d} />)}
      </g>

      {/* Inland water — painted back over land in ocean color */}
      <g fill="var(--ocean)" stroke="rgba(17,17,17,0.32)" strokeWidth="0.5" strokeLinejoin="round" clipPath="url(#globeClip)">
        {lakePaths.map((d, i) => <path key={i} d={d} />)}
      </g>

      {/* Faint land fill to avoid self-intersection artifacts */}

      {/* Visited airport dots */}
      {!demoMode && labelDensity !== 'none' && visited.map(code => {
        const a = window.AIRPORTS[code];
        if (!a) return null;
        const p = project(rotation, a[0], a[1], r, cx, cy);
        if (!p.visible) return null;
        const isActive = active && (active.o === code || active.d === code);
        const showLabel = labelDensity === 'all' || isActive;
        return (
          <g key={code}>
            <circle cx={p.x} cy={p.y} r={isActive ? 3.5 : 2.2}
              fill={isActive ? accent : 'rgba(17,17,17,0.7)'}
              stroke="var(--ocean)" strokeWidth={isActive ? 1 : 1.2}
            />
            {showLabel && (
              <text x={p.x + 7} y={p.y - 5}
                fontSize={isActive ? 11 : 9}
                fontWeight={isActive ? 600 : 500}
                fill={isActive ? 'var(--ink)' : 'var(--muted)'}
                style={{ paintOrder: 'stroke', stroke: 'var(--ocean)', strokeWidth: 3, fontFamily: 'var(--font-mono)' }}
              >{code}</text>
            )}
          </g>
        );
      })}

      {/* Demo arcs (landing screen) */}
      {demoArcPaths.map(arc => (
        <g key={`demo-${arc.id}`}>
          <path
            d={arc.d}
            fill="none"
            stroke={accent}
            strokeWidth="1.5"
            strokeLinecap="round"
            style={{ animation: `demoArc ${arc.dur}ms ease-out forwards` }}
            pathLength="1"
            strokeDasharray="1"
            strokeDashoffset="1"
          />
          {arc.oP.visible && (
            <g style={{ animation: `demoDot ${arc.dur}ms ease-out forwards`, transformOrigin: `${arc.oP.x}px ${arc.oP.y}px` }}>
              <circle cx={arc.oP.x} cy={arc.oP.y} r="3" fill={accent} />
              <circle cx={arc.oP.x} cy={arc.oP.y} r="3" fill="none" stroke={accent} strokeWidth="1.2"
                style={{ animation: 'demoPulse 1600ms ease-out infinite', transformOrigin: `${arc.oP.x}px ${arc.oP.y}px` }} />
            </g>
          )}
          {arc.dP.visible && (
            <g style={{ animation: `demoDot ${arc.dur/2}ms ease-out ${arc.dur/2}ms forwards`, transformOrigin: `${arc.dP.x}px ${arc.dP.y}px`, opacity: 0 }}>
              <circle cx={arc.dP.x} cy={arc.dP.y} r="3" fill={accent} />
              <circle cx={arc.dP.x} cy={arc.dP.y} r="3" fill="none" stroke={accent} strokeWidth="1.2"
                style={{ animation: `demoPulse 1600ms ease-out ${arc.dur/2}ms infinite`, transformOrigin: `${arc.dP.x}px ${arc.dP.y}px` }} />
            </g>
          )}
        </g>
      ))}

      {/* Arc */}
      {active && arcD && (
        <g>
          <path d={arcD} fill="none" stroke={accent} strokeOpacity="0.2" strokeWidth="1" />
          <path
            key={animId}
            ref={arcRef}
            d={arcD}
            fill="none"
            stroke={accent}
            strokeWidth="1.75"
            strokeLinecap="round"
            strokeDasharray={isDashed ? '5 5' : (arcLen || 2000)}
            strokeDashoffset={isDashed ? 0 : (arcLen || 2000)}
            style={{ animation: isDashed ? 'dash 1.2s linear infinite' : 'draw 1100ms ease-out forwards' }}
          />
        </g>
      )}

      {/* Endpoint markers */}
      {endpoints && endpoints.o.visible && (
        <g>
          <circle cx={endpoints.o.x} cy={endpoints.o.y} r="5" fill="none" stroke={accent} strokeWidth="1.2" opacity="0.5">
            <animate attributeName="r" from="4" to="12" dur="1.8s" repeatCount="indefinite" />
            <animate attributeName="opacity" from="0.6" to="0" dur="1.8s" repeatCount="indefinite" />
          </circle>
          <circle cx={endpoints.o.x} cy={endpoints.o.y} r="3.5" fill={accent} />
          <text x={endpoints.o.x + 9} y={endpoints.o.y - 7}
            fontSize="12" fontWeight="700" fill="var(--ink)"
            style={{ paintOrder: 'stroke', stroke: 'var(--ocean)', strokeWidth: 3.5, fontFamily: 'var(--font-mono)' }}
          >{active.o}</text>
        </g>
      )}
      {endpoints && endpoints.d.visible && (
        <g>
          <circle cx={endpoints.d.x} cy={endpoints.d.y} r="5" fill="none" stroke={accent} strokeWidth="1.2" opacity="0.5">
            <animate attributeName="r" from="4" to="12" dur="1.8s" begin="0.9s" repeatCount="indefinite" />
            <animate attributeName="opacity" from="0.6" to="0" dur="1.8s" begin="0.9s" repeatCount="indefinite" />
          </circle>
          <circle cx={endpoints.d.x} cy={endpoints.d.y} r="3.5" fill={accent} />
          <text x={endpoints.d.x + 9} y={endpoints.d.y - 7}
            fontSize="12" fontWeight="700" fill="var(--ink)"
            style={{ paintOrder: 'stroke', stroke: 'var(--ocean)', strokeWidth: 3.5, fontFamily: 'var(--font-mono)' }}
          >{active.d}</text>
        </g>
      )}
    </svg>
  );
}

/* ============ Plane photo ============ */
function PlanePhoto({ tail }) {
  const [photo, setPhoto] = useState(null);
  const [status, setStatus] = useState('loading');
  const [imgLoaded, setImgLoaded] = useState(false);
  useEffect(() => {
    if (!tail) return;
    let cancelled = false;
    setStatus('loading'); setPhoto(null); setImgLoaded(false);
    const promise = window.prefetchPlanePhoto
      ? window.prefetchPlanePhoto(tail)
      : (() => {
          return fetch(`/jetapi/api?reg=${encodeURIComponent(tail)}&photos=1`)
            .then(r => r.json())
            .then(d => d?.JetPhotos?.Images?.[0] || null);
        })();
    console.log('[kyp:photo] PlanePhoto effect for tail', tail);
    Promise.resolve(promise)
      .then(img => {
        if (cancelled) { console.log('[kyp:photo] resolved but cancelled for', tail); return; }
        console.log('[kyp:photo] resolved image for', tail, img);
        if (!img) { setStatus('empty'); return; }
        setPhoto(img); setStatus('ready');
      })
      .catch((err) => { console.error('[kyp:photo] PlanePhoto resolve error', tail, err); if (!cancelled) setStatus('error'); });
    return () => { cancelled = true; };
  }, [tail]);
  if (status === 'empty' || status === 'error') return null;
  const showImg = status === 'ready' && photo;
  return (
    <a
      className="plane-photo"
      href={showImg ? photo.Link : undefined}
      target={showImg ? '_blank' : undefined}
      rel="noreferrer"
    >
      <div className={`plane-photo-frame${!imgLoaded ? ' is-loading' : ''}`}>
        {!imgLoaded && <div className="plane-photo-placeholder" aria-label="Loading aircraft photo" />}
        {showImg && (
          <img
            src={photo.Thumbnail}
            alt={`${tail} at ${photo.Location}`}
            ref={(el) => {
              if (el && el.complete && el.naturalWidth > 0) {
                console.log('[kyp:photo] <img> already complete', photo.Thumbnail, el.naturalWidth + 'x' + el.naturalHeight);
                setImgLoaded(true);
              }
            }}
            onLoad={(e) => { console.log('[kyp:photo] <img> onLoad', photo.Thumbnail, e.target.naturalWidth + 'x' + e.target.naturalHeight); setImgLoaded(true); }}
            onError={(e) => { console.error('[kyp:photo] <img> onError', photo.Thumbnail, e); }}
            style={{ display: imgLoaded ? 'block' : 'none' }}
          />
        )}
      </div>
      <div className="plane-photo-credit">
        {showImg && imgLoaded ? <>© {photo.Photographer} · JetPhotos</> : <>Loading…</>}
      </div>
    </a>
  );
}

/* ============ Typewriter ============ */
function useTypewriter(target, speed = 5) {
  const [text, setText] = useState(target);
  const textRef = useRef(target);
  textRef.current = text;
  useEffect(() => {
    if (textRef.current === target) return;
    let cancelled = false;
    let timer = null;
    const tick = () => {
      if (cancelled) return;
      const cur = textRef.current;
      if (cur === target) return;
      const typing = target.startsWith(cur) && cur.length < target.length;
      const next = typing ? target.slice(0, cur.length + 1) : cur.slice(0, -1);
      textRef.current = next;
      setText(next);
      // Erase faster than type so total erase+type time ≈ type time alone.
      const delay = typing ? speed : Math.max(1, Math.floor(speed / 3));
      if (next !== target) timer = setTimeout(tick, delay);
    };
    timer = setTimeout(tick, speed);
    return () => { cancelled = true; if (timer) clearTimeout(timer); };
  }, [target, speed]);
  return text;
}
function TypeText({ text, speed }) {
  const t = useTypewriter(text, speed);
  return t;
}
function FitTitle({ className, text, showFlight }) {
  const ref = useRef(null);
  const t = useTypewriter(text);
  React.useLayoutEffect(() => {
    const el = ref.current; if (!el) return;
    const parent = el.parentElement; if (!parent) return;
    const cs = getComputedStyle(parent);
    const avail = parent.clientWidth - parseFloat(cs.paddingLeft || 0) - parseFloat(cs.paddingRight || 0);
    if (avail <= 0) return;
    const maxPx = 32;
    const minPx = 10;
    let size = maxPx;
    el.style.fontSize = size + 'px';
    while (el.scrollWidth > avail && size > minPx) {
      size -= 1;
      el.style.fontSize = size + 'px';
    }
  });
  const idx = t.indexOf(' → ');
  const content = idx === -1
    ? (t.length ? t : '\u00A0')
    : <>{t.slice(0, idx)} <em>→</em> {t.slice(idx + 3)}</>;
  return <div ref={ref} className={className}>{content}</div>;
}
function TypeTitle({ text, speed }) {
  const t = useTypewriter(text, speed);
  const idx = t.indexOf(' → ');
  if (idx === -1) return t.length ? t : '\u00A0';
  return <>{t.slice(0, idx)} <em>→</em> {t.slice(idx + 3)}</>;
}

/* ============ Sidebar ============ */
function Sidebar({ selected, setSelected, loadState }) {
  const p = window.PLANE;
  const showFlight = !!selected;
  const O = selected ? window.AIRPORTS[selected.o] : null;
  const D = selected ? window.AIRPORTS[selected.d] : null;
  const isLoading = loadState?.status === 'loading';
  const isError = loadState?.status === 'error';
  const titleText = showFlight
    ? `${cityOf(O?.[3]) || selected.o} → ${cityOf(D?.[3]) || selected.d}`
    : (p.tail ? p.tail : (isLoading || isError ? loadState.message : ''));
  const meta = showFlight
    ? [
        ['Duration', fmtDur(selected.dur)],
        ['Departed', String(selected.dep)],
        ['Arrived', String(selected.arr)],
      ]
    : [
        ['Delivered', String(p.delivered ?? '')],
        ['Flights', typeof p.cycles === 'number' ? p.cycles.toLocaleString() : String(p.cycles ?? '')],
        ['Hours', typeof p.hours === 'number' ? p.hours.toLocaleString() : String(p.hours ?? '')],
      ];
  return (
    <aside className="sidebar">
      <div className="side-head">
        <button className="side-arrow side-arrow-back"
          aria-label={showFlight ? 'Back to aircraft' : 'New search'}
          onClick={() => showFlight ? setSelected(null) : (window.__reopenStarter && window.__reopenStarter())}>←</button>
        <FitTitle className={showFlight ? 'flight-title' : 'tail'} text={titleText} showFlight={showFlight} />
        <div className="meta-row">
          {meta.map(([label, val], i) => (
            <span key={i}><TypeText text={label} /> <strong><TypeText text={val} /></strong></span>
          ))}
        </div>
        <PlanePhoto tail={p.tail} />
        <div className="model">{p.model} <em>· {p.operator}</em></div>
      </div>
      <div className="list-head">
        <div className="list-title">Flight history</div>
        <div className="list-count">{window.FLIGHTS.length} flights</div>
      </div>
      <div className="list">
        {window.FLIGHTS.map(f => (
          <button key={f.id}
            className={`flight ${selected?.id === f.id ? 'selected' : ''}`}
            onClick={() => setSelected(f)}
          >
            <div className="flight-row-1">
              <span className="f-no">{f.no}</span>
              <span className="f-date">{fmtShortDate(f.date)}</span>
            </div>
            <div className="f-route">
              <span>{f.o}</span>
              <span className="f-arrow">→</span>
              <span>{f.d}</span>
            </div>
            <div className="f-dur">
              <span>{fmtDur(f.dur)}</span>
              <span className="f-dash">·</span>
              <span>{fmtMi(f.dist)} mi</span>
            </div>
          </button>
        ))}
      </div>
    </aside>
  );
}

/* ============ Detail overlay ============ */
function Detail({ flight }) {
  if (!flight) return (
    <div className="detail">
      <div className="detail-no">Select a flight</div>
      <div className="detail-title">Fly <em>anywhere</em> this plane has been.</div>
      <div className="detail-cities">Pick a route from the list to draw its path.</div>
    </div>
  );
  const O = window.AIRPORTS[flight.o], D = window.AIRPORTS[flight.d];
  return (
    <div className="detail">
      <div className="detail-head">
        <div className="detail-head-l">
          <div className="detail-no">{flight.no} · {fmtDate(flight.date)}</div>
          <div className="detail-title">{O[3]} <em>→</em> {D[3]}</div>
          <div className="detail-cities">{O[4]} to {D[4]}</div>
        </div>
        <div className="detail-stats">
          <span><strong>{fmtDur(flight.dur)}</strong><em>Duration</em></span>
          <span><strong>{fmtMi(flight.dist)}</strong><em>Miles</em></span>
          <span><strong>{flight.dep}</strong><em>Departed</em></span>
        </div>
      </div>
    </div>
  );
}

/* ============ Tweaks ============ */
function Tweaks({ tweaks, set, visible }) {
  if (!visible) return null;
  const accents = ['#c14c2c', '#2a4d3a', '#1e3a8a', '#6b2d8a', '#c4a21f'];
  return (
    <div className="tweaks">
      <h4>Tweaks</h4>
      <div className="tw">
        <div className="tw-l">Arc style</div>
        <div className="tw-opts">
          {['curved','straight','dashed'].map(v => (
            <button key={v} className={`tw-b ${tweaks.arcStyle === v ? 'on':''}`}
              onClick={() => set({...tweaks, arcStyle: v})}>{v}</button>
          ))}
        </div>
      </div>
      <div className="tw">
        <div className="tw-l">Accent</div>
        <div className="tw-sw">
          {accents.map(c => (
            <button key={c} className={`sw ${tweaks.accent === c ? 'on':''}`}
              style={{background: c}} onClick={() => set({...tweaks, accent: c})} />
          ))}
        </div>
      </div>
      <div className="tw">
        <div className="tw-l">Labels</div>
        <div className="tw-opts">
          {['all','selected','none'].map(v => (
            <button key={v} className={`tw-b ${tweaks.labelDensity === v ? 'on':''}`}
              onClick={() => set({...tweaks, labelDensity: v})}>{v}</button>
          ))}
        </div>
      </div>
    </div>
  );
}

/* ============ App ============ */
function App() {
  const [rotation, setRotation] = useState([80, 25]);
  const [zoom, setZoom] = useState(1);
  const [selected, setSelected] = useState(null);
  const [hover, setHover] = useState(null);
  const [tweaks, setTweaks] = useState(TWEAK_DEFAULTS);
  const [tweakVisible, setTweakVisible] = useState(false);
  const [animKey, setAnimKey] = useState(0);
  const [stage, setStage] = useState({ w: 1060, h: 800, base: 600 });
  const windowRef = useRef(null);
  const [, setDataVersion] = useState(0);
  const [loadState, setLoadState] = useState({ status: 'idle', message: '' });

  // Kick off a real-data lookup from URL params or the starter form event.
  useEffect(() => {
    let cancelled = false;
    const run = async (params) => {
      const tail = params.get ? params.get('tail') : params.tail;
      if (!tail) return;
      if (!window.FlightData) return;
      setLoadState({ status: 'loading', message: `${tail}…` });
      setSelected(null);
      // Clear existing data up front so progressive updates render onto a clean slate.
      for (const k of Object.keys(window.AIRPORTS)) delete window.AIRPORTS[k];
      window.FLIGHTS.splice(0, window.FLIGHTS.length);
      for (const k of Object.keys(window.PLANE)) delete window.PLANE[k];
      setDataVersion(v => v + 1);
      const onProgress = (ev) => {
        if (cancelled) return;
        if (ev.plane) Object.assign(window.PLANE, ev.plane);
        if (ev.airports) Object.assign(window.AIRPORTS, ev.airports);
        if (ev.flight) window.FLIGHTS.push(ev.flight);
        setLoadState({ status: 'loading', message: 'Loading…' });
        setDataVersion(v => v + 1);
      };
      try {
        const res = await window.FlightData.loadByTail(tail, onProgress);
        if (cancelled) return;
        // Final sync — ensures ordering matches the returned result.
        for (const k of Object.keys(window.AIRPORTS)) delete window.AIRPORTS[k];
        Object.assign(window.AIRPORTS, res.airports);
        window.FLIGHTS.splice(0, window.FLIGHTS.length, ...res.flights);
        for (const k of Object.keys(window.PLANE)) delete window.PLANE[k];
        Object.assign(window.PLANE, res.plane);
        setDataVersion(v => v + 1);
        setLoadState({
          status: res.flights.length ? 'ready' : 'empty',
          message: res.flights.length ? '' : 'No recent flights found for this aircraft.',
        });
        window.dispatchEvent(new CustomEvent('kyp:lookup-success'));
      } catch (e) {
        if (cancelled) return;
        const msg = e.message || String(e);
        setLoadState({ status: 'error', message: msg });
        window.dispatchEvent(new CustomEvent('kyp:lookup-error', { detail: { message: msg } }));
      }
    };
    // Run immediately if URL params are already present (e.g. refresh).
    const initial = new URLSearchParams(location.search);
    if (initial.get('tail')) run(initial);
    const onLookup = (e) => run(e.detail || new URLSearchParams(location.search));
    const onReset = () => {
      setSelected(null);
      for (const k of Object.keys(window.AIRPORTS)) delete window.AIRPORTS[k];
      window.FLIGHTS.splice(0, window.FLIGHTS.length);
      for (const k of Object.keys(window.PLANE)) delete window.PLANE[k];
      setDataVersion(v => v + 1);
      setLoadState({ status: 'idle', message: '' });
    };
    window.addEventListener('kyp:lookup', onLookup);
    window.addEventListener('kyp:reset', onReset);
    return () => {
      cancelled = true;
      window.removeEventListener('kyp:lookup', onLookup);
      window.removeEventListener('kyp:reset', onReset);
    };
  }, []);

  useEffect(() => { document.documentElement.style.setProperty('--accent', tweaks.accent); }, [tweaks.accent]);

  useEffect(() => {
    const r = () => {
      const el = windowRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const w = Math.max(100, Math.round(rect.width));
      const h = Math.max(100, Math.round(rect.height));
      setStage(prev => (prev.w === w && prev.h === h) ? prev : { w, h, base: Math.min(w, h) * 0.96 });
    };
    r();
    window.addEventListener('resize', r);
    const ro = new ResizeObserver(r);
    if (windowRef.current) ro.observe(windowRef.current);
    return () => { window.removeEventListener('resize', r); ro.disconnect(); };
  }, []);

  const [demoArcs, setDemoArcs] = useState([]);
  const rotationRef = useRef(rotation);
  rotationRef.current = rotation;
  const demoMode = loadState.status === 'idle';
  useEffect(() => {
    if (!demoMode) { setDemoArcs([]); return; }
    const COORDS = [
      [40.6413, -73.7781],   // JFK
      [51.4700, -0.4543],    // LHR
      [35.7720, 140.3929],   // NRT
      [-33.9399, 151.1753],  // SYD
      [25.2532, 55.3657],    // DXB
      [-33.9715, 18.6021],   // CPT
      [-23.4356, -46.4731],  // GRU
      [1.3644, 103.9915],    // SIN
      [33.9416, -118.4085],  // LAX
      [22.3080, 113.9185],   // HKG
      [19.4361, -99.0719],   // MEX
      [55.9736, 37.4125],    // SVO
      [28.5562, 77.1000],    // DEL
      [-34.8222, -58.5358],  // EZE
      [37.6188, -122.3754],  // SFO
      [41.9794, -87.9044],   // ORD
      [50.0379, 8.5622],     // FRA
      [49.0097, 2.5479],     // CDG
      [-1.3192, 36.9278],    // NBO
      [-37.0082, 174.7850],  // AKL
      [21.3187, -157.9225],  // HNL
      // Africa
      [30.1219, 31.4056],    // CAI Cairo
      [-26.1392, 28.2460],   // JNB Johannesburg
      [6.5774, 3.3212],      // LOS Lagos
      [8.9778, 38.7993],     // ADD Addis Ababa
      [33.3675, -7.5898],    // CMN Casablanca
      [14.6708, -17.0734],   // DSS Dakar
      [-4.3818, 15.4446],    // FIH Kinshasa
      [36.6910, 3.2155],     // ALG Algiers
      // South America
      [4.7016, -74.1469],    // BOG Bogota
      [-12.0219, -77.1143],  // LIM Lima
      [-33.3930, -70.7858],  // SCL Santiago
      [-22.8100, -43.2506],  // GIG Rio de Janeiro
      [10.6013, -66.9911],   // CCS Caracas
      [-0.1292, -78.3575],   // UIO Quito
      [-16.5133, -68.1925],  // LPB La Paz
      [-25.2390, -57.5194],  // ASU Asuncion
      // Asia
      [40.0799, 116.6031],   // PEK Beijing
      [31.1443, 121.8083],   // PVG Shanghai
      [37.4602, 126.4407],   // ICN Seoul
      [13.6900, 100.7501],   // BKK Bangkok
      [2.7456, 101.7099],    // KUL Kuala Lumpur
      [-6.1256, 106.6559],   // CGK Jakarta
      [19.0896, 72.8656],    // BOM Mumbai
      [14.5086, 121.0194],   // MNL Manila
      [25.0777, 121.2328],   // TPE Taipei
      [25.2731, 51.6080],    // DOH Doha
      [40.9769, 28.8146],    // IST Istanbul
      [24.9008, 67.1681],    // KHI Karachi
      [23.8433, 90.3978],    // DAC Dhaka
      [21.2212, 105.8072],   // HAN Hanoi
      [13.0827, 80.2707],    // MAA Chennai
      [10.8231, 106.6297],   // SGN Ho Chi Minh
    ];
    // Project a [lat, lon] onto the current rotation; return true if on the visible hemisphere.
    const isVisible = ([lat, lon]) => {
      const p = project(rotationRef.current, lat, lon, 1, 0, 0);
      return p.z > 0.15; // small margin so endpoints aren't right on the limb
    };
    const SPAWN_INTERVAL = 1000;
    const angularDist = ([lat1, lon1], [lat2, lon2]) => {
      const toR = Math.PI / 180;
      const a = Math.sin((lat2 - lat1) * toR / 2) ** 2
        + Math.cos(lat1 * toR) * Math.cos(lat2 * toR) * Math.sin((lon2 - lon1) * toR / 2) ** 2;
      return 2 * Math.asin(Math.min(1, Math.sqrt(a))); // 0..π
    };
    const pick = () => {
      const visible = COORDS.filter(isVisible);
      if (visible.length < 2) return null;
      const a = Math.floor(Math.random() * visible.length);
      let b = Math.floor(Math.random() * visible.length);
      while (b === a) b = Math.floor(Math.random() * visible.length);
      const o = visible[a], d = visible[b];
      // Scale duration with great-circle distance: ~3s for tiny hops, ~9s for half-globe.
      const frac = angularDist(o, d) / Math.PI; // 0..1
      const dur = Math.round(3000 + frac * 6000);
      return { id: `${Date.now()}-${Math.random().toString(36).slice(2,7)}`, o, d, dur, born: performance.now() };
    };
    const tick = () => {
      const now = performance.now();
      setDemoArcs(prev => {
        const fresh = prev.filter(a => now - a.born < a.dur);
        const next = pick();
        return next ? [...fresh, next] : fresh;
      });
    };
    tick();
    const iv = setInterval(tick, SPAWN_INTERVAL);
    return () => { clearInterval(iv); setDemoArcs([]); };
  }, [demoMode]);

  // Ambient drift only when no selection
  useEffect(() => {
    if (selected) return;
    let raf, last = performance.now();
    const tick = (now) => {
      const dt = now - last; last = now;
      setRotation(([lon, lat]) => [lon - dt * 0.004, lat]);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [selected]);

  useEffect(() => {
    const onKey = (e) => {
      if (e.key !== 'Escape') return;
      if (selected) setSelected(null);
      else if (typeof window.__reopenStarter === 'function') window.__reopenStarter();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selected]);

  useEffect(() => {
    const h = (e) => {
      if (!e.data || typeof e.data !== 'object') return;
      if (e.data.type === '__activate_edit_mode') setTweakVisible(true);
      if (e.data.type === '__deactivate_edit_mode') setTweakVisible(false);
    };
    window.addEventListener('message', h);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', h);
  }, []);

  const updateTweaks = (next) => {
    setTweaks(next);
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*');
  };

  const pick = (f) => { setSelected(f); setAnimKey(k => k + 1); };

  return (
    <div className="app">
      <Sidebar selected={selected} setSelected={pick} loadState={loadState} />
      <div className="stage" ref={windowRef}>
        <Globe
          rotation={rotation}
          setRotation={setRotation}
          zoom={zoom}
          setZoom={setZoom}
          width={stage.w}
          height={stage.h}
          baseSize={stage.base}
          selected={selected}
          hover={hover}
          accent={tweaks.accent}
          arcStyle={tweaks.arcStyle}
          labelDensity={tweaks.labelDensity}
          animKey={animKey}
          demoArcs={demoArcs}
          demoMode={demoMode}
        />
      </div>
      <Tweaks tweaks={tweaks} set={updateTweaks} visible={tweakVisible} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);

} catch(e) { document.body.insertAdjacentHTML('afterbegin', '<pre style="color:red;font:11px monospace;padding:20px">'+(e.stack||e.message||e)+'</pre>'); }
})();
