const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ============== GLOBE (d3-geo + canvas) ==============
// Uses world-atlas TopoJSON (public domain) loaded at runtime.
const WORLD_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
const WORLD_LAND_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/land-110m.json";

let _worldDataPromise = null;
let _textureImagePromise = null;
// NASA Blue Marble (equirectangular, 2048×1024, public domain) — real photographic earth
const EARTH_TEXTURE_URL = "https://unpkg.com/three-globe@2.30.0/example/img/earth-blue-marble.jpg";

function loadEarthTexture() {
  if (_textureImagePromise) return _textureImagePromise;
  _textureImagePromise = new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      // Draw to offscreen canvas to get ImageData for pixel sampling
      const c = document.createElement("canvas");
      c.width = img.naturalWidth;
      c.height = img.naturalHeight;
      const cc = c.getContext("2d");
      cc.drawImage(img, 0, 0);
      try {
        const imageData = cc.getImageData(0, 0, c.width, c.height);
        resolve({ img, imageData, width: c.width, height: c.height });
      } catch (e) {
        console.warn("Earth texture tainted, using fallback", e);
        resolve(null);
      }
    };
    img.onerror = () => { console.warn("Earth texture failed to load"); resolve(null); };
    img.src = EARTH_TEXTURE_URL;
  });
  return _textureImagePromise;
}

function loadWorld() {
  if (_worldDataPromise) return _worldDataPromise;
  _worldDataPromise = Promise.all([
    fetch(WORLD_URL).then(r => r.json()),
    fetch(WORLD_LAND_URL).then(r => r.json()),
  ]).then(([countriesTopo, landTopo]) => {
    const countries = topojson.feature(countriesTopo, countriesTopo.objects.countries);
    const borders = topojson.mesh(countriesTopo, countriesTopo.objects.countries, (a, b) => a !== b);
    const land = topojson.feature(landTopo, landTopo.objects.land);
    return { countries, borders, land };
  }).catch(err => { console.error("World data load failed", err); return null; });
  return _worldDataPromise;
}

function Globe({ activePlaceId, onPlaceClick, size = 520 }) {
  const canvasRef = useRef(null);
  const [rot, setRot] = useState({ lon: 90, lat: -30 }); // d3 rotation (negate display)
  const [target, setTarget] = useState({ lon: 90, lat: -30, zoom: 1 });
  const [zoom, setZoom] = useState(1);
  const [world, setWorld] = useState(null);
  const [texture, setTexture] = useState(null);
  const [hoverPlaceId, setHoverPlaceId] = useState(null);
  const dragRef = useRef(null);
  const rafRef = useRef(null);
  const stateRef = useRef({ rot, zoom });
  stateRef.current = { rot, zoom };

  // DPR-aware canvas
  const dpr = typeof window !== "undefined" ? (window.devicePixelRatio || 1) : 1;

  // Load world data
  useEffect(() => {
    let mounted = true;
    loadWorld().then(data => { if (mounted) setWorld(data); });
    loadEarthTexture().then(tex => { if (mounted) setTexture(tex); });
    return () => { mounted = false; };
  }, []);

  // Projection — note d3-geo uses [lon, lat] with orthographic centered on rotation
  const projection = useMemo(() => {
    if (typeof d3 === "undefined") return null;
    // IMPORTANT: baseScale is the RADIUS of the sphere in pixels. The disc we DRAW
    // is larger (we fill a disc of radius drawR) so continents occupy less of
    // the visible disc — matching real photos of Earth where ocean dominates.
    const baseScale = (size / 2) * 0.86;
    const p = d3.geoOrthographic()
      .translate([size / 2, size / 2])
      .scale(baseScale * zoom)
      .rotate([rot.lon, rot.lat, 0])
      .clipAngle(90);
    return p;
  }, [rot.lon, rot.lat, zoom, size]);

  // Paint
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !projection) return;
    const ctx = canvas.getContext("2d");
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, size, size);

    const path = d3.geoPath(projection, ctx);
    const cx = size / 2, cy = size / 2;
    const drawR = (size / 2) * 0.86 * zoom;

    // ===== Atmosphere halo (drawn before sphere, outside clip) =====
    const haloR = drawR + 16;
    const halo = ctx.createRadialGradient(cx, cy, drawR - 2, cx, cy, haloR);
    halo.addColorStop(0, "rgba(150,200,235,0.55)");
    halo.addColorStop(0.4, "rgba(130,185,225,0.22)");
    halo.addColorStop(1, "rgba(130,185,225,0)");
    ctx.fillStyle = halo;
    ctx.beginPath();
    ctx.arc(cx, cy, haloR, 0, Math.PI * 2);
    ctx.fill();

    // ===== Photorealistic sphere via per-pixel texture sampling =====
    if (texture) {
      // Orthographic sphere math: for each pixel (px,py) inside disc,
      // compute the point on the unit sphere (x,y,z), rotate it by inverse
      // of d3's rotation, then compute lat/lon and sample the equirectangular
      // texture. This gives exact orthographic proportions.
      const lonDeg = -rot.lon * Math.PI / 180; // d3 rotates by -lon for display
      const latDeg = -rot.lat * Math.PI / 180;
      const cosLat = Math.cos(latDeg), sinLat = Math.sin(latDeg);
      const cosLon = Math.cos(lonDeg), sinLon = Math.sin(lonDeg);

      // Resolution: render at internal res = drawR*2 * dpr, then ctx transforms
      // handle the scaling. But to keep cost down, use CSS-pixel resolution.
      const R = Math.floor(drawR);
      const D = R * 2;
      const off = document.createElement("canvas");
      off.width = D; off.height = D;
      const octx = off.getContext("2d");
      const imgData = octx.createImageData(D, D);
      const data = imgData.data;
      const tex = texture.imageData.data;
      const TW = texture.width, TH = texture.height;

      // Sun direction (upper-left), normalized in screen space; converts to
      // simple lambertian shading on the sphere surface.
      const sunX = -0.5, sunY = -0.55, sunZ = 0.65;
      const sLen = Math.hypot(sunX, sunY, sunZ);
      const sx = sunX / sLen, sy = sunY / sLen, sz = sunZ / sLen;

      for (let py = 0; py < D; py++) {
        const ny = (py - R + 0.5) / R; // -1..1, screen Y (flipped later)
        for (let px = 0; px < D; px++) {
          const nx = (px - R + 0.5) / R; // -1..1, screen X
          const r2 = nx * nx + ny * ny;
          if (r2 > 1) continue; // outside disc
          // Point on unit sphere facing camera (+Z out of screen)
          // Screen X→+X_cam, Screen Y (down)→-Y_cam
          const X = nx;
          const Y = -ny;
          const Z = Math.sqrt(1 - r2);

          // Un-rotate: d3 rotates world by [lon, lat]. We apply the INVERSE.
          // First undo lat rotation (about X axis by +lat)
          const Y1 = Y * cosLat + Z * sinLat;
          const Z1 = -Y * sinLat + Z * cosLat;
          const X1 = X;
          // Then undo lon rotation (about Y axis by +lon)
          const X2 = X1 * cosLon + Z1 * sinLon;
          const Z2 = -X1 * sinLon + Z1 * cosLon;
          const Y2 = Y1;

          // Convert world-space sphere point to geographic lat/lon
          const lat = Math.asin(Y2);
          const lon = Math.atan2(X2, Z2);

          // Sample equirectangular texture
          let tu = (lon / Math.PI + 1) * 0.5; // 0..1
          let tv = 0.5 - lat / Math.PI;       // 0..1 (top=90, bottom=-90)
          if (tu < 0) tu += 1; if (tu >= 1) tu -= 1;
          const tx = Math.min(TW - 1, Math.max(0, (tu * TW) | 0));
          const ty = Math.min(TH - 1, Math.max(0, (tv * TH) | 0));
          const ti = (ty * TW + tx) * 4;
          let r = tex[ti], g = tex[ti + 1], b = tex[ti + 2];

          // Lambertian shade (dot product of normal with sun)
          const dot = X * sx + Y * sy + Z * sz;
          const shade = 0.18 + 0.9 * Math.max(0, dot); // ambient + diffuse
          r = Math.min(255, r * shade);
          g = Math.min(255, g * shade);
          b = Math.min(255, b * shade);

          // Limb darkening (edge of sphere)
          const limb = 1 - Math.pow(r2, 3) * 0.35;
          r *= limb; g *= limb; b *= limb;

          const di = (py * D + px) * 4;
          data[di] = r; data[di + 1] = g; data[di + 2] = b; data[di + 3] = 255;
        }
      }
      octx.putImageData(imgData, 0, 0);
      ctx.drawImage(off, cx - R, cy - R, D, D);
    } else if (world) {
      // Fallback: vector-only while texture loads
      ctx.save();
      ctx.beginPath();
      ctx.arc(cx, cy, drawR, 0, Math.PI * 2);
      ctx.clip();
      ctx.fillStyle = "#2a5a7a";
      ctx.fill();
      ctx.fillStyle = "#9c7d4e";
      ctx.beginPath();
      path(world.land);
      ctx.fill();
      ctx.restore();
    }

    // Clip for overlays
    ctx.save();
    ctx.beginPath();
    ctx.arc(cx, cy, drawR, 0, Math.PI * 2);
    ctx.clip();

    // Thin country borders on top of texture
    if (world) {
      ctx.strokeStyle = "rgba(255,255,255,0.18)";
      ctx.lineWidth = 0.5;
      ctx.beginPath();
      path(world.borders);
      ctx.stroke();
    }

    // Very faint graticule
    const graticule = d3.geoGraticule10();
    ctx.strokeStyle = "rgba(255,255,255,0.05)";
    ctx.lineWidth = 0.5;
    ctx.beginPath();
    path(graticule);
    ctx.stroke();

    // Specular highlight
    const spec = ctx.createRadialGradient(cx - drawR * 0.4, cy - drawR * 0.45, drawR * 0.02, cx - drawR * 0.4, cy - drawR * 0.45, drawR * 0.45);
    spec.addColorStop(0, "rgba(255,255,255,0.22)");
    spec.addColorStop(1, "rgba(255,255,255,0)");
    ctx.fillStyle = spec;
    ctx.beginPath();
    ctx.arc(cx, cy, drawR, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();

    // Outer rim
    ctx.strokeStyle = "rgba(0,0,0,0.4)";
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.arc(cx, cy, drawR, 0, Math.PI * 2);
    ctx.stroke();
  }, [projection, world, texture, size, zoom, dpr, rot.lon, rot.lat]);

  // Animation loop toward target
  useEffect(() => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
    const animate = () => {
      const { rot: cur, zoom: curZ } = stateRef.current;
      let dLon = target.lon - cur.lon;
      while (dLon > 180) dLon -= 360;
      while (dLon < -180) dLon += 360;
      const dLat = target.lat - cur.lat;
      const dZoom = target.zoom - curZ;
      if (Math.abs(dLon) < 0.1 && Math.abs(dLat) < 0.1 && Math.abs(dZoom) < 0.005) {
        setRot({ lon: target.lon, lat: target.lat });
        setZoom(target.zoom);
        return;
      }
      setRot({ lon: cur.lon + dLon * 0.1, lat: cur.lat + dLat * 0.1 });
      setZoom(curZ + dZoom * 0.1);
      rafRef.current = requestAnimationFrame(animate);
    };
    rafRef.current = requestAnimationFrame(animate);
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [target.lon, target.lat, target.zoom]);

  // Fly to active place — d3 rotation is negative of geographic center
  useEffect(() => {
    if (!activePlaceId) return;
    const place = window.PLACES.find(p => p.id === activePlaceId);
    if (!place) return;
    setTarget({
      lon: -place.coords[0],
      lat: -Math.min(70, Math.max(-70, place.coords[1])),
      zoom: 1,
    });
  }, [activePlaceId]);

  // Pointer handlers
  const onPointerDown = (e) => {
    dragRef.current = { x: e.clientX, y: e.clientY, lon: rot.lon, lat: rot.lat, moved: false };
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e) => {
    if (!dragRef.current) return;
    const dx = e.clientX - dragRef.current.x;
    const dy = e.clientY - dragRef.current.y;
    if (Math.abs(dx) + Math.abs(dy) > 2) dragRef.current.moved = true;
    // Convert pixel delta to arc-degrees on the sphere surface.
    // At 1x zoom, full hemisphere (~180°) spans ~ size*0.96 pixels diameter → ~0.52°/px.
    // Higher zoom magnifies pixels so each px represents fewer degrees.
    const degPerPx = 180 / (size * 0.96 * zoom);
    // Latitude factor: horizontal drag should rotate more longitude when near equator,
    // less when near poles (where longitude bunches). Divide by cos(lat) to compensate.
    const cosLat = Math.max(0.15, Math.cos(dragRef.current.lat * Math.PI / 180));
    const newLon = dragRef.current.lon + (dx * degPerPx) / cosLat;
    const newLat = Math.max(-85, Math.min(85, dragRef.current.lat - dy * degPerPx));
    setTarget({ lon: newLon, lat: newLat, zoom });
    setRot({ lon: newLon, lat: newLat });
  };
  const onPointerUp = () => { dragRef.current = null; };
  const onWheel = (e) => {
    e.preventDefault();
    const delta = -e.deltaY * 0.002;
    const nz = Math.max(1, Math.min(4, zoom + delta));
    setTarget({ ...target, zoom: nz });
    setZoom(nz);
  };
  const zoomTo = (z) => {
    const nz = Math.max(1, Math.min(4, z));
    setTarget({ lon: target.lon, lat: target.lat, zoom: nz });
  };
  const resetView = () => setTarget({ lon: 30, lat: -20, zoom: 1 });

  // Pin positions (computed via projection) — must match drawR used in paint
  const places = window.PLACES;
  const drawR = (size / 2) * 0.86 * zoom;
  const pinDOM = projection ? places.map((p) => {
    const xy = projection(p.coords);
    if (!xy || !isFinite(xy[0]) || !isFinite(xy[1])) return null;
    // Great-circle visibility test: only show points on the visible hemisphere
    const centerLon = -rot.lon, centerLat = -rot.lat;
    const phi1 = centerLat * Math.PI / 180;
    const phi = p.coords[1] * Math.PI / 180;
    const lam = (p.coords[0] - centerLon) * Math.PI / 180;
    const cosC = Math.sin(phi1) * Math.sin(phi) + Math.cos(phi1) * Math.cos(phi) * Math.cos(lam);
    if (cosC < 0.05) return null;
    // Extra safety: pin must fall inside the drawn disc radius
    const cx = size / 2, cy = size / 2;
    const dist = Math.hypot(xy[0] - cx, xy[1] - cy);
    if (dist > drawR + 2) return null;
    return { ...p, sx: xy[0], sy: xy[1] };
  }).filter(Boolean) : [];

  return (
    <div style={{ position: "relative", width: "100%", maxWidth: size, margin: "0 auto" }}>
      <canvas
        ref={canvasRef}
        width={size * dpr}
        height={size * dpr}
        style={{
          width: "100%", height: "auto", display: "block",
          cursor: dragRef.current ? "grabbing" : "grab",
          touchAction: "none", userSelect: "none",
          aspectRatio: "1 / 1",
        }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onWheel={onWheel}
      />

      {/* Pin overlay */}
      <svg viewBox={`0 0 ${size} ${size}`}
           style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
        {pinDOM.map((p) => {
          const isActive = p.id === activePlaceId;
          const isHover = p.id === hoverPlaceId;
          return (
            <g key={p.id} style={{ pointerEvents: "auto", cursor: "pointer" }}
               onPointerDown={(e) => e.stopPropagation()}
               onClick={(e) => {
                 e.stopPropagation();
                 if (!dragRef.current || !dragRef.current.moved) onPlaceClick(p.id);
               }}
               onMouseEnter={() => setHoverPlaceId(p.id)}
               onMouseLeave={() => setHoverPlaceId(null)}>
              {isActive && (
                <circle cx={p.sx} cy={p.sy} r="18" fill="#ffd97a" opacity="0.3">
                  <animate attributeName="r" values="10;22;10" dur="2.4s" repeatCount="indefinite" />
                  <animate attributeName="opacity" values="0.4;0.1;0.4" dur="2.4s" repeatCount="indefinite" />
                </circle>
              )}
              <circle cx={p.sx} cy={p.sy} r={isActive ? 6 : isHover ? 5 : 4}
                      fill={isActive ? "#ffd97a" : "#ff6b4a"}
                      stroke="#ffffff" strokeWidth="1.5" />
              {(isActive || isHover) && (
                <text x={p.sx} y={p.sy - 14} textAnchor="middle"
                      style={{ font: "600 11px var(--font-ui, sans-serif)", fill: "#fff", paintOrder: "stroke", stroke: "rgba(0,0,0,0.75)", strokeWidth: 3, strokeLinejoin: "round", pointerEvents: "none" }}>
                  {p.label.split(",")[0]}
                </text>
              )}
            </g>
          );
        })}
      </svg>

      {/* Loading state */}
      {!world && (
        <div style={{
          position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
          fontFamily: "var(--font-ui)", fontSize: "0.7rem", letterSpacing: "0.15em", textTransform: "uppercase",
          color: "rgba(255,255,255,0.7)", pointerEvents: "none"
        }}>
          Loading globe…
        </div>
      )}

      {/* Zoom controls */}
      <div style={{ position: "absolute", right: "0.5rem", bottom: "0.5rem", display: "flex", flexDirection: "column", gap: 0, background: "rgba(255,255,255,0.9)", border: "1px solid var(--rule)", borderRadius: "6px", overflow: "hidden", backdropFilter: "blur(6px)" }}>
        <button onClick={() => zoomTo(zoom + 0.5)} style={zoomBtnStyle} aria-label="Zoom in">+</button>
        <div style={{ height: 1, background: "var(--rule)" }} />
        <button onClick={() => zoomTo(zoom - 0.5)} style={zoomBtnStyle} aria-label="Zoom out">−</button>
        <div style={{ height: 1, background: "var(--rule)" }} />
        <button onClick={resetView} style={{ ...zoomBtnStyle, fontSize: "0.85rem" }} aria-label="Reset view">⌂</button>
      </div>
      <div style={{ position: "absolute", left: "0.5rem", bottom: "0.5rem", fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace", fontSize: "0.65rem", color: "var(--ink)", background: "rgba(255,255,255,0.85)", padding: "3px 8px", borderRadius: "4px", letterSpacing: "0.05em" }}>
        {zoom.toFixed(1)}×
      </div>
    </div>
  );
}

const zoomBtnStyle = {
  width: 30, height: 30, border: "none", background: "transparent",
  cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "1rem",
  color: "var(--ink)", display: "flex", alignItems: "center", justifyContent: "center",
};

// ============== PLACE PANEL ==============
function PlacePanel({ place }) {
  if (!place) {
    return (
      <div style={{ padding: "1.25rem 0", color: "var(--ink-soft)", fontFamily: "var(--font-body)", fontSize: "0.92rem", lineHeight: 1.6 }}>
        Drag the globe, scroll to zoom, or click a moment on the timeline below.
      </div>
    );
  }
  return (
    <div key={place.id} style={{ animation: "fadeSlide 500ms ease" }}>
      <div className="eyebrow" style={{ marginBottom: "0.5rem", color: "var(--accent)" }}>{place.region}</div>
      <div style={{ fontFamily: "var(--font-display)", fontSize: "clamp(1.35rem, 2vw, 1.75rem)", marginBottom: "0.6rem", lineHeight: 1.15 }}>
        {place.label}
      </div>
      <p className="prose" style={{ fontSize: "0.95rem", marginBottom: "1.1rem" }}>{place.blurb}</p>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.5rem" }}>
        {place.photos.map((ph, i) => (
          <div key={i} className="photo" style={{ aspectRatio: "3/4" }}>
            <span className="photo-label" style={{ fontSize: "0.55rem" }}>{ph}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ============== TIMELINE (condensed, with residency bars) ==============
// Parse an event's `date` string (e.g. "May 2017", "Spring 2014", "Today") into
// a fractional year position so events months apart don't stack on top of each
// other. Falls back to ev.year when the date string has no month information.
function eventFractionalYear(ev) {
  const monthMap = {
    january: 1, jan: 1, february: 2, feb: 2, march: 3, mar: 3,
    april: 4, apr: 4, may: 5, june: 6, jun: 6, july: 7, jul: 7,
    august: 8, aug: 8, september: 9, sept: 9, sep: 9,
    october: 10, oct: 10, november: 11, nov: 11, december: 12, dec: 12,
    spring: 4, summer: 7, fall: 10, autumn: 10, winter: 1,
  };
  const s = (ev.date || "").toLowerCase();
  if (s === "today") return ev.year + 4 / 12; // anchor "today" near current month
  for (const k of Object.keys(monthMap)) {
    if (s.includes(k)) return ev.year + (monthMap[k] - 0.5) / 12;
  }
  return ev.year + 0.5;
}

// Horizontal deconfliction: walk events left-to-right. Each card prefers its
// natural X (above its dot) but shifts rightward if needed to avoid
// overlapping the previous card. Returns Map<_idx, cardCenterX>.
function assignCardCentersHoriz(events, naturalX, cardW, gap) {
  const sorted = [...events].sort((a, b) => naturalX(a) - naturalX(b));
  const result = new Map();
  let prevRight = -Infinity;
  for (const ev of sorted) {
    const nat = naturalX(ev);
    const minCenter = prevRight + cardW / 2 + gap;
    const center = Math.max(nat, minCenter);
    result.set(ev._idx, center);
    prevRight = center + cardW / 2;
  }
  return result;
}

function Timeline({ activeEventKey, activePlaceId, onEventClick }) {
  const events = window.TIMELINE_EVENTS;
  const residencies = window.RESIDENCIES || [];
  const scrollerRef = useRef(null);

  const CARD_W              = 120;
  const CARD_H              = 48;
  const CARD_GAP            = 6;
  const BAR_H               = 16;

  // Build sorted/decorated events with fractional X
  const sorted = useMemo(() => {
    return [...events]
      .map((e, i) => ({ ...e, _idx: i, _x: eventFractionalYear(e) }))
      .sort((a, b) => a._x - b._x);
  }, []);

  // Year range
  const MIN_YEAR = Math.floor(Math.min(...sorted.map(e => e._x))) - 1;
  const MAX_YEAR = 2026;
  const YEAR_W = 70;
  const yearToX = (y) => (y - MIN_YEAR) * YEAR_W + 30;
  const naturalTotalW = (MAX_YEAR - MIN_YEAR) * YEAR_W + 80;

  const MEET_YEAR = 2017.42; // June 2017 (move-in)

  // Horizontal card placement — cards shift right when they'd overlap a neighbor
  const cardCenters = useMemo(() => {
    const cn = (ev) => yearToX(ev._x);
    return {
      beth:     assignCardCentersHoriz(sorted.filter(e => e.track === "beth"),     cn, CARD_W, CARD_GAP),
      zach:     assignCardCentersHoriz(sorted.filter(e => e.track === "zach"),     cn, CARD_W, CARD_GAP),
      together: assignCardCentersHoriz(sorted.filter(e => e.track === "together"), cn, CARD_W, CARD_GAP),
    };
  }, [sorted]);

  // Total width must accommodate any card pushed past the natural right edge
  const maxCardRight = Math.max(
    naturalTotalW - 30,
    ...Object.values(cardCenters).flatMap(m => [...m.values()].map(c => c + CARD_W / 2))
  );
  const totalW = maxCardRight + 30;

  // Fixed vertical layout — single row of cards per track
  const ROW_BETH_BAR        = 18;
  const ROW_BETH_EV         = 38;
  const ROW_BETH_CARDS      = 50;   // top of Beth card row
  const ROW_TOG_BAR         = 122;
  const ROW_TOG_EV          = 142;
  const ROW_TOG_CARDS       = 154;  // top of Together card row
  const ROW_ZACH_CARDS      = 222;  // top of Zach card row (cards above dots)
  const ROW_ZACH_EV         = 282;
  const ROW_ZACH_BAR        = 294;
  const ROW_YEARS           = 312;
  const TOTAL_H             = 334;

  // Auto-scroll active event into view
  useEffect(() => {
    if (!activeEventKey) return;
    const el = scrollerRef.current?.querySelector(`[data-ev-key="${activeEventKey}"]`);
    if (el) el.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
  }, [activeEventKey]);

  // Drag-to-pan on the horizontal scroller
  useEffect(() => {
    const el = scrollerRef.current;
    if (!el) return;
    let isDown = false;
    let startX = 0;
    let startScroll = 0;
    let moved = false;

    const onDown = (e) => {
      // Only left mouse / primary touch, and ignore drags starting on interactive children
      if (e.button !== undefined && e.button !== 0) return;
      const t = e.target;
      if (t.closest && t.closest("button, a")) return;
      isDown = true;
      moved = false;
      startX = (e.touches ? e.touches[0].pageX : e.pageX) - el.offsetLeft;
      startScroll = el.scrollLeft;
      el.style.cursor = "grabbing";
      el.style.userSelect = "none";
    };
    const onMove = (e) => {
      if (!isDown) return;
      const x = (e.touches ? e.touches[0].pageX : e.pageX) - el.offsetLeft;
      const dx = x - startX;
      if (Math.abs(dx) > 3) moved = true;
      el.scrollLeft = startScroll - dx;
      if (e.cancelable && e.touches) e.preventDefault();
    };
    const onUp = () => {
      if (!isDown) return;
      isDown = false;
      el.style.cursor = "grab";
      el.style.removeProperty("user-select");
    };
    // Suppress click bubbling if a real drag occurred
    const onClickCapture = (e) => {
      if (moved) {
        e.stopPropagation();
        e.preventDefault();
        moved = false;
      }
    };

    el.style.cursor = "grab";
    el.addEventListener("mousedown", onDown);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    el.addEventListener("touchstart", onDown, { passive: true });
    el.addEventListener("touchmove", onMove, { passive: false });
    el.addEventListener("touchend", onUp);
    el.addEventListener("click", onClickCapture, true);
    return () => {
      el.removeEventListener("mousedown", onDown);
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      el.removeEventListener("touchstart", onDown);
      el.removeEventListener("touchmove", onMove);
      el.removeEventListener("touchend", onUp);
      el.removeEventListener("click", onClickCapture, true);
    };
  }, []);

  // Event detail popover
  const [hoverKey, setHoverKey] = useState(null);

  // Render a residency bar
  const renderBar = (r, i, rowTop, color) => {
    const x1 = yearToX(r.startYear);
    const endYear = r.endYear === "present" ? MAX_YEAR : r.endYear;
    const x2 = yearToX(endYear);
    const isActive = activePlaceId && r.placeId === activePlaceId;
    return (
      <div key={`${r.person}-${i}`}
        onClick={() => onEventClick({ placeId: r.placeId, title: r.label }, null)}
        title={`${r.label} · ${r.startYear} to ${r.endYear}`}
        style={{
          position: "absolute",
          left: `${x1}px`,
          top: `${rowTop}px`,
          width: `${x2 - x1}px`,
          height: "16px",
          background: color,
          opacity: isActive ? 1 : 0.82,
          borderRadius: "3px",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          paddingLeft: "7px",
          fontFamily: "var(--font-ui)",
          fontSize: "0.62rem",
          letterSpacing: "0.08em",
          color: color === "var(--ink)" ? "var(--bg)" : "#fff",
          fontWeight: 500,
          boxShadow: isActive ? "0 2px 8px -2px rgba(0,0,0,0.35)" : "none",
          outline: isActive ? "2px solid #ffd97a" : "none",
          outlineOffset: "1px",
          transition: "opacity 180ms, outline 180ms",
          overflow: "hidden",
          whiteSpace: "nowrap",
        }}>
        {x2 - x1 > 60 ? r.label : ""}
      </div>
    );
  };

  return (
    <div style={{ width: "100%" }}>
      <div style={{ display: "flex", gap: "1.25rem", fontFamily: "var(--font-ui)", fontSize: "0.7rem", color: "var(--ink-soft)", marginBottom: "0.6rem", letterSpacing: "0.05em", flexWrap: "wrap", alignItems: "center" }}>
        <span style={{ display: "inline-flex", alignItems: "center", gap: "6px" }}>
          <span style={{ width: 14, height: 8, borderRadius: "2px", background: "var(--accent)" }} /> Beth
        </span>
        <span style={{ display: "inline-flex", alignItems: "center", gap: "6px" }}>
          <span style={{ width: 14, height: 8, borderRadius: "2px", background: "var(--ink)" }} /> Zach
        </span>
        <span style={{ display: "inline-flex", alignItems: "center", gap: "6px" }}>
          <span style={{ width: 22, height: 8, borderRadius: "2px", background: "linear-gradient(90deg, var(--accent), var(--ink))" }} /> Together
        </span>
        <span style={{ marginLeft: "auto", fontStyle: "italic", opacity: 0.7 }}>bars = where we lived · dots = moments · drag to pan</span>
      </div>

      <div ref={scrollerRef} className="timeline-scroll" style={{
        position: "relative",
        overflowX: "auto",
        overflowY: "visible",
        padding: "0.25rem 0 0.5rem",
        scrollbarWidth: "thin",
      }}>
        <div style={{ position: "relative", width: `${totalW}px`, height: `${TOTAL_H}px` }}>

          {/* Faint horizontal guides on event rows */}
          <div style={{ position: "absolute", left: 0, right: 0, top: `${ROW_BETH_EV + 5}px`, height: 1, background: "var(--rule)", opacity: 0.5 }} />
          <div style={{ position: "absolute", left: 0, right: 0, top: `${ROW_TOG_EV + 5}px`, height: 1, background: "var(--rule)", opacity: 0.5 }} />
          <div style={{ position: "absolute", left: 0, right: 0, top: `${ROW_ZACH_EV + 5}px`, height: 1, background: "var(--rule)", opacity: 0.5 }} />

          {/* Track row labels (absolute, far left) */}
          <div style={{ position: "absolute", left: 4, top: `${ROW_BETH_BAR + 2}px`, fontFamily: "var(--font-ui)", fontSize: "0.58rem", letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--accent)", background: "var(--bg-soft)", paddingRight: 4 }}>Beth</div>
          <div style={{ position: "absolute", left: 4, top: `${ROW_TOG_BAR + 2}px`, fontFamily: "var(--font-ui)", fontSize: "0.58rem", letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--ink)", background: "var(--bg-soft)", paddingRight: 4 }}>Us</div>
          <div style={{ position: "absolute", left: 4, top: `${ROW_ZACH_BAR + 2}px`, fontFamily: "var(--font-ui)", fontSize: "0.58rem", letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--ink)", background: "var(--bg-soft)", paddingRight: 4 }}>Zach</div>

          {/* "We move in together" marker — June 2017 */}
          <div style={{ position: "absolute", left: `${yearToX(MEET_YEAR)}px`, top: `${ROW_BETH_BAR}px`, width: 2, height: `${ROW_ZACH_BAR + 16 - ROW_BETH_BAR}px`, background: "var(--accent)", opacity: 0.25 }} />

          {/* Residency bars */}
          {residencies.filter(r => r.person === "beth").map((r, i) => renderBar(r, i, ROW_BETH_BAR, "var(--accent)"))}
          {residencies.filter(r => r.person === "together").map((r, i) => renderBar(r, i, ROW_TOG_BAR, "linear-gradient(90deg, var(--accent), var(--ink))"))}
          {residencies.filter(r => r.person === "zach").map((r, i) => renderBar(r, i, ROW_ZACH_BAR, "var(--ink)"))}

          {/* Year ticks */}
          {Array.from({ length: MAX_YEAR - MIN_YEAR + 1 }).map((_, i) => {
            const y = MIN_YEAR + i;
            const showLabel = y % 5 === 0 || y === MAX_YEAR || y === MIN_YEAR + 1;
            return (
              <React.Fragment key={y}>
                <div style={{ position: "absolute", left: `${yearToX(y)}px`, top: `${ROW_YEARS - 6}px`, width: 1, height: showLabel ? 8 : 4, background: "var(--rule)", opacity: 0.7 }} />
                {showLabel && (
                  <div style={{ position: "absolute", left: `${yearToX(y) - 14}px`, top: `${ROW_YEARS + 4}px`, fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace", fontSize: "0.65rem", color: "var(--ink-soft)", opacity: 0.7 }}>{y}</div>
                )}
              </React.Fragment>
            );
          })}

          {/* Event dots + always-visible blurb cards */}
          {sorted.map((ev, i) => {
            const cx = yearToX(ev._x);
            const cy = ev.track === "beth" ? ROW_BETH_EV : ev.track === "zach" ? ROW_ZACH_EV : ROW_TOG_EV;
            const key = `${ev.year}-${ev._idx}`;
            const isActive = activeEventKey === key || (activePlaceId && ev.placeId === activePlaceId && !activeEventKey);
            const color = ev.track === "beth" ? "var(--accent)" : ev.track === "zach" ? "var(--ink)" : "#c1855a";

            const placeAbove = ev.track === "zach"; // Zach cards sit above dots
            const cardCenter = cardCenters[ev.track].get(ev._idx) ?? cx;

            const cardTop =
              ev.track === "beth"     ? ROW_BETH_CARDS :
              ev.track === "together" ? ROW_TOG_CARDS  :
                                        ROW_ZACH_CARDS;

            // Keep card within timeline width
            let cardLeft = cardCenter - CARD_W / 2;
            if (cardLeft < 4) cardLeft = 4;
            if (cardLeft + CARD_W > totalW - 4) cardLeft = totalW - CARD_W - 4;

            // Leader: line from dot (cx, cy) diagonally to top/bottom edge of card at cardCenter.
            const leaderY1 = placeAbove ? cy - 4 : cy + 4;
            const leaderY2 = placeAbove ? cardTop + CARD_H : cardTop;
            const leaderX2 = cardLeft + CARD_W / 2;

            return (
              <React.Fragment key={key}>
                {/* Leader line — diagonal from dot to displaced card edge */}
                <svg style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }}>
                  <line x1={cx} y1={leaderY1} x2={leaderX2} y2={leaderY2}
                        stroke={color} strokeWidth={isActive ? 1.5 : 1} opacity={isActive ? 0.9 : 0.4} />
                </svg>

                {/* Blurb card */}
                <button data-ev-key={key}
                  onClick={() => onEventClick(ev, key)}
                  title={`${ev.date} · ${ev.title}`}
                  style={{
                    position: "absolute",
                    left: `${cardLeft}px`,
                    top: `${cardTop}px`,
                    width: `${CARD_W}px`,
                    height: `${CARD_H}px`,
                    background: isActive ? "var(--ink)" : "var(--bg)",
                    color: isActive ? "var(--bg)" : "var(--ink)",
                    border: `1px solid ${isActive ? "var(--ink)" : "var(--rule)"}`,
                    borderLeft: isActive ? undefined : `3px solid ${color}`,
                    borderRadius: "3px",
                    padding: "6px 8px",
                    fontFamily: "var(--font-ui)",
                    fontSize: "0.68rem",
                    lineHeight: 1.3,
                    textAlign: "left",
                    cursor: "pointer",
                    boxShadow: isActive ? "0 6px 18px -8px rgba(0,0,0,0.35)" : "0 1px 2px rgba(0,0,0,0.04)",
                    transition: "all 180ms",
                    overflow: "hidden",
                    zIndex: isActive ? 10 : 2,
                  }}>
                  <div style={{ fontSize: "0.55rem", letterSpacing: "0.12em", textTransform: "uppercase", opacity: 0.72, marginBottom: "2px" }}>{ev.date}</div>
                  <div style={{ fontFamily: "var(--font-display)", fontSize: "0.82rem", fontWeight: 500, lineHeight: 1.15, letterSpacing: "-0.01em", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{ev.title}</div>
                </button>

                {/* Dot on track */}
                <div onClick={() => onEventClick(ev, key)}
                  style={{
                    position: "absolute",
                    left: `${cx - 6}px`,
                    top: `${cy - 6}px`,
                    width: 12, height: 12,
                    borderRadius: "50%",
                    background: color,
                    border: "2px solid var(--bg)",
                    boxShadow: isActive ? `0 0 0 3px ${ev.track === "zach" ? "rgba(31,27,24,0.35)" : "rgba(138,106,74,0.4)"}` : "0 1px 2px rgba(0,0,0,0.15)",
                    cursor: "pointer",
                    transition: "all 180ms",
                    zIndex: isActive ? 9 : 3,
                  }}
                />
              </React.Fragment>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ============== STORY CHAPTER ==============
function ChapterStoryInteractive() {
  // Build chronological event list (oldest first)
  const chronoEvents = useMemo(() => {
    const evs = (window.TIMELINE_EVENTS || []).map((e, i) => ({ ...e, _idx: i }));
    return [...evs].sort((a, b) => {
      if (a.year !== b.year) return a.year - b.year;
      // tie-break by original index for stable order
      return a._idx - b._idx;
    });
  }, []);

  // Start at the earliest event
  const firstEv = chronoEvents[0];
  const firstKey = firstEv ? `${firstEv.year}-${firstEv._idx}` : null;
  const [activePlaceId, setActivePlaceId] = useState(firstEv ? firstEv.placeId : "boise");
  const [activeEventKey, setActiveEventKey] = useState(firstKey);
  const [autoPlay, setAutoPlay] = useState(false);

  const place = window.PLACES.find(p => p.id === activePlaceId);

  // Auto-play through events in chronological order until user interrupts
  useEffect(() => {
    if (!autoPlay || chronoEvents.length === 0) return;
    const interval = setInterval(() => {
      setActiveEventKey(prevKey => {
        const curIdx = chronoEvents.findIndex(e => `${e.year}-${e._idx}` === prevKey);
        const nextIdx = (curIdx + 1) % chronoEvents.length;
        const next = chronoEvents[nextIdx];
        if (next.placeId) setActivePlaceId(next.placeId);
        return `${next.year}-${next._idx}`;
      });
    }, 4000); // 4s per event
    return () => clearInterval(interval);
  }, [autoPlay, chronoEvents]);

  const stopAutoPlay = () => { if (autoPlay) setAutoPlay(false); };

  const onPlaceClick = (id) => {
    stopAutoPlay();
    setActivePlaceId(id);
    const events = window.TIMELINE_EVENTS;
    const idx = events.findIndex(e => e.placeId === id);
    if (idx >= 0) setActiveEventKey(`${events[idx].year}-${idx}`);
  };

  const onEventClick = (ev, key) => {
    stopAutoPlay();
    setActiveEventKey(key);
    if (ev.placeId) setActivePlaceId(ev.placeId);
  };

  return (
    <section className="chapter chapter-story-interactive" data-screen-label="03 Story" id="chapter-story" style={{ background: "var(--bg-soft)", minHeight: "auto" }}
      onWheel={stopAutoPlay} onPointerDown={stopAutoPlay} onTouchStart={stopAutoPlay}>
      <div style={{ width: "100%", maxWidth: "1400px", margin: "0 auto", display: "flex", flexDirection: "column", gap: "clamp(1.5rem, 3vh, 2.5rem)" }}>
        <div className="story-header">
          <div className="eyebrow" style={{ marginBottom: "0.75rem" }}>Chapter 03</div>
          <h2 className="display display-lg" style={{ maxWidth: "24ch", marginBottom: "0.5rem" }}>How our story began.</h2>
          <p className="prose" style={{ color: "var(--ink-soft)", fontSize: "0.95rem" }}>
            Two paths that finally met at Purdue in 2016. Drag the globe, scroll to zoom, or pick a moment, and they move together.
          </p>
          {autoPlay && (
            <div style={{ marginTop: "0.75rem", display: "inline-flex", alignItems: "center", gap: "0.5rem", fontFamily: "var(--font-ui)", fontSize: "0.72rem", color: "var(--ink-soft)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
              <span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", animation: "autoPulse 1.6s ease-in-out infinite" }} />
              auto playing, click anything to take control
              <button onClick={() => setAutoPlay(false)} style={{ marginLeft: "0.5rem", background: "transparent", border: "1px solid var(--rule)", borderRadius: 4, padding: "2px 8px", cursor: "pointer", fontFamily: "inherit", fontSize: "0.7rem", color: "var(--ink)", letterSpacing: "0.04em" }}>pause</button>
            </div>
          )}
          {!autoPlay && (
            <div style={{ marginTop: "0.75rem" }}>
              <button onClick={() => setAutoPlay(true)} style={{ background: "transparent", border: "1px solid var(--rule)", borderRadius: 4, padding: "3px 10px", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "0.7rem", color: "var(--ink-soft)", letterSpacing: "0.06em", textTransform: "uppercase" }}>▶ play story</button>
            </div>
          )}
        </div>

        <div className="story-top" style={{ display: "grid", gridTemplateColumns: "minmax(0, 0.85fr) minmax(0, 1.15fr)", gap: "clamp(1.5rem, 3vw, 3rem)", alignItems: "center" }}>
          <div style={{ display: "flex", justifyContent: "center" }}>
            <Globe activePlaceId={activePlaceId} onPlaceClick={onPlaceClick} size={440} />
          </div>
          <div>
            <PlacePanel place={place} />
          </div>
        </div>

        <div>
          <Timeline activeEventKey={activeEventKey} activePlaceId={activePlaceId} onEventClick={onEventClick} />
        </div>
      </div>

      <style>{`
        .chapter-story-interactive { padding-top: clamp(3rem, 7vh, 5rem) !important; padding-bottom: clamp(2rem, 5vh, 4rem) !important; }
        @keyframes fadeSlide { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
        @keyframes autoPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
        .timeline-scroll::-webkit-scrollbar { height: 8px; }
        .timeline-scroll::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 4px; }
        .timeline-scroll::-webkit-scrollbar-track { background: transparent; }
        @media (max-width: 900px) {
          .chapter-story-interactive .story-top { grid-template-columns: 1fr !important; }
        }
      `}</style>
    </section>
  );
}

window.ChapterStoryInteractive = ChapterStoryInteractive;
