// Direction A — PROJECTION (real photo data)
// Continuous streaming filmstrip using real photos from the archive,
// chained by shared people using the same logic as the /live page.
// ?preview=true bypasses the odyssey rate limiter.

// Fallback slug generator matching server's slugify(name, {lower:true,strict:true,remove:/[*+~.()'"!:@]/g})
function toMatchSlug(name) {
  return name.trim()
    .toLowerCase()
    .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
    .replace(/[*+~.()'"!:@]/g, '')
    .replace(/[^a-z0-9\s-]/g, '')
    .trim()
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-');
}

function formatPhotoLocation(location, fallback = 'Unknown Location') {
  const value = (location || '').trim();
  if (!value || value === 'N/A') return fallback;
  if (value === 'Unknown') return 'Unknown Location';
  return value;
}

const STREAM_SPEED_BASE = 69;
const CARD_W = 360;
const CARD_GAP = 220;
const CARD_STRIDE = CARD_W + CARD_GAP;
const CHAIN_PREFETCH = 6; // keep this many photos ahead of the visible window
const HOMEPAGE_IDLE_TIMEOUT_MS = 5 * 60 * 1000;

// Autocomplete roster from server-provided names
const ROSTER = (() => {
  const serverNames = window.INITIAL_NAMES;
  if (serverNames && serverNames.length > 0) {
    return serverNames.map(n => (typeof n === 'string' ? n : n.name)).filter(Boolean).sort();
  }
  return [];
})();

// Archive stats — real counts from server if available
const STATS = (() => {
  const s = window.ARCHIVE_STATS || {};
  const people = s.people || (window.INITIAL_NAMES ? window.INITIAL_NAMES.length : 25390);
  const photos = s.photos || 0;
  return { people: Number(people).toLocaleString(), photos: Number(photos).toLocaleString() };
})();

// RAF-based streaming x position
function useStreamX(speed, paused) {
  const xRef = React.useRef(0);
  const pausedRef = React.useRef(paused);
  pausedRef.current = paused;
  const [, force] = React.useState(0);
  React.useEffect(() => {
    let raf, last = performance.now(), alive = true;
    const tick = (now) => {
      if (!alive) return;
      const dt = Math.min(0.1, (now - last) / 1000);
      last = now;
      if (!pausedRef.current) {
        xRef.current += STREAM_SPEED_BASE * speed * dt;
        force(n => (n + 1) % 1_000_000);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => { alive = false; cancelAnimationFrame(raf); };
  }, [speed]);
  return xRef;
}

// ─── Real image card ────────────────────────────────────────────
// Shows the actual photo with a dark striped loading state.
// Parses annotations on load and calls onImageData so the parent
// SVG can draw positioned connection boxes.
function RealPhotoCard({ photo, mode, cardH, onImageData }) {
  const [loaded, setLoaded] = React.useState(false);

  const handleLoad = React.useCallback((e) => {
    const img = e.target;
    const natW = img.naturalWidth  || CARD_W;
    const natH = img.naturalHeight || cardH;
    const ar = natW / natH;
    // Width when image is displayed at full card height (no cropping)
    const actualW = Math.round(cardH * ar);
    const annotations = parseAnnotations(photo);

    const people = (photo.People || []).map(person => {
      const ann = annotations.find(a =>
        (a.personName === person.Name || a.name === person.Name) ||
        (a.personId === person.PersonID || a.personID === person.PersonID || a.PersonID === person.PersonID)
      );
      if (!ann) return null;
      const box = extractBox(ann, natW, natH, actualW, cardH);
      if (!box) return null;
      return {
        name: person.Name,
        x: (box.left + box.width  / 2) / actualW,
        y: (box.top  + box.height / 2) / cardH,
        r: box.width / (2 * actualW),
      };
    }).filter(Boolean);

    onImageData(photo.PhotoID, people, ar);
    setLoaded(true);
  }, [photo, cardH, onImageData]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%', background: '#0a0908', overflow: 'hidden' }}>
      {/* Loading shimmer — same dark stripe aesthetic as placeholders */}
      {!loaded && (
        <div style={{
          position: 'absolute', inset: 0,
          backgroundImage: 'repeating-linear-gradient(-15deg, rgba(30,25,20,0.85) 0 3px, rgba(10,9,8,0.9) 3px 14px)',
        }} />
      )}
      <img
        src={photo.PhotoMeta}
        alt={photo.PhotoDescription || ''}
        onLoad={handleLoad}
        onError={() => setLoaded(false)}
        style={{
          display: 'block',
          width: '100%', height: '100%',
          opacity: loaded ? 1 : 0,
          transition: 'opacity 0.5s ease',
          filter: mode === 'bw' ? 'grayscale(100%)' : 'none',
        }}
      />
      {/* Film grain overlay */}
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.18 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
        mixBlendMode: 'overlay', opacity: 0.5,
      }} />
      {/* Vignette */}
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.5) 100%)',
      }} />
    </div>
  );
}

// ─── Photo caption with auto-shrinking font ───────────────────
// Renders the featured photo's names, shrinking the font size in
// 0.5px steps until all names fit within exactly 3 wrapped lines.
function PhotoCaption({ photo, inboundName, outboundName }) {
  const namesRef = React.useRef(null);
  const allPeople = photo.People || [];
  const connectionNames = [inboundName, outboundName].filter(Boolean);
  const getPersonName = (person) => person.Name || person.name || '';
  const byName = (a, b) => getPersonName(a).localeCompare(getPersonName(b));
  const connectionPeople = allPeople
    .filter(p => connectionNames.includes(getPersonName(p)))
    .sort(byName);
  const otherPeople = allPeople
    .filter(p => !connectionNames.includes(getPersonName(p)))
    .sort(byName);
  const people = [...connectionPeople, ...otherPeople];
  const year = photo.Year || photo.year || '';
  const location = formatPhotoLocation(photo.Location || photo.location, '');
  const photographer = photo.Photographer || photo.photographer || '';

  React.useLayoutEffect(() => {
    const el = namesRef.current;
    if (!el || people.length === 0) return;
    // Reset to CSS default, then shrink until content fits in 3 lines.
    // Measure against actual lineHeight px so the target stays accurate as
    // font-size decreases (lineHeight is 1.45em so it scales with the font).
    el.style.fontSize = '';
    const MIN_SIZE = 9;
    let size = parseFloat(getComputedStyle(el).fontSize);
    while (size > MIN_SIZE) {
      const lh = parseFloat(getComputedStyle(el).lineHeight);
      if (el.scrollHeight <= lh * 3 + 2) break;
      size -= 0.5;
      el.style.fontSize = size + 'px';
    }
  }, [photo.PhotoID, inboundName, outboundName]);

  return (
    <div className="proj-caption" key={'cap-' + photo.PhotoID}>
      <div className="proj-caption-names" ref={namesRef}>
        {people.map((p, i) => {
          const isConnectionName = i < connectionPeople.length;
          const sepClass = 'caption-sep' + (
            isConnectionName && i < connectionPeople.length - 1
              ? ' caption-sep--primary'
              : i === connectionPeople.length - 1 && otherPeople.length > 0
                ? ' caption-sep--bridge'
                : ''
          );

          return (
            <React.Fragment key={i}>
              <span className={'caption-name' + (isConnectionName ? ' caption-name--primary' : ' caption-name--secondary')}>
                {getPersonName(p)}
              </span>
              {i < people.length - 1 && <span className={sepClass}> · </span>}
            </React.Fragment>
          );
        })}
      </div>
      {(year || location || photographer) && (
        <div className="proj-caption-meta mono">
          {[year, location, photographer ? 'Photo: ' + photographer : ''].filter(Boolean).join(' / ')}
        </div>
      )}
    </div>
  );
}

// ─── Slideshow photo detail overlay ───────────────────────────
// Pops up when a filmstrip photo is clicked. Reuses pv-* styles.
function PhotoDetailOverlay({ photo, inboundName, outboundName, onClose }) {
  const imgRef  = React.useRef(null);
  const wrapRef = React.useRef(null);
  const [imgLoaded, setImgLoaded] = React.useState(false);
  const [annBoxes, setAnnBoxes] = React.useState([]);
  const [hovered,  setHovered]  = React.useState(false);
  const [hoveredAnn, setHoveredAnn] = React.useState(null);
  const [hoveredName, setHoveredName] = React.useState(null);
  const [personModal, setPersonModal] = React.useState(null);

  const imgUrl = photo.PhotoMeta || '';
  const locationLabel = formatPhotoLocation(photo.PhotoLocation);
  const allPeople = photo.People || [];
  const connectionNames = [inboundName, outboundName].filter(Boolean);
  const connectionPeople = allPeople
    .filter(p => connectionNames.includes(p.Name || p.name))
    .sort((a, b) => (a.Name || a.name || '').localeCompare(b.Name || b.name || ''));
  const otherPeople = allPeople
    .filter(p => !connectionNames.includes(p.Name || p.name))
    .sort((a, b) => (a.Name || a.name || '').localeCompare(b.Name || b.name || ''));
  const people = [...connectionPeople, ...otherPeople];

  // Keyboard: ESC closes modal then overlay
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {
        if (personModal) { setPersonModal(null); return; }
        onClose();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [personModal, onClose]);

  // Build annotation boxes after image loads
  const handleImgLoad = React.useCallback(() => {
    const img  = imgRef.current;
    const wrap = wrapRef.current;
    if (!img || !wrap) { setImgLoaded(true); return; }
    const natW = img.naturalWidth  || img.clientWidth;
    const natH = img.naturalHeight || img.clientHeight;
    const rendW = img.clientWidth;
    const rendH = img.clientHeight;
    const offX = (wrap.clientWidth  - rendW) / 2;
    const offY = (wrap.clientHeight - rendH) / 2;
    const annotations = parseAnnotations({ Annotations: photo.Annotations });
    const boxes = annotations.map(ann => {
      const box = extractBox(ann, natW, natH, rendW, rendH);
      if (!box) return null;
      const name = ann.personName || ann.name || null;
      return {
        box: { left: offX + box.left, top: offY + box.top, width: box.width, height: box.height },
        name,
        isBridge: false,
      };
    }).filter(Boolean);
    setAnnBoxes(boxes);
    setImgLoaded(true);
  }, [photo]);

  return (
    <div
      className="pdo-backdrop"
      onClick={onClose}
    >
      {/* Modal panel — stop clicks bubbling to backdrop */}
      <div className="pdo-panel" onClick={e => e.stopPropagation()}>
        {/* Header */}
        <div className="pdo-header">
          <div className="pdo-year-loc mono">
            {(photo.PhotoDate && photo.PhotoDate !== 'Unknown' ? photo.PhotoDate : 'Unknown Date')} · {locationLabel}
          </div>
          <button className="pv-btn mono" onClick={onClose} aria-label="Close">
            <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
              <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
            </svg>
          </button>
        </div>

        {/* Body: image + meta */}
        <div className="pdo-body">
          {/* Image */}
          <div className="pdo-image-col">
            {imgUrl ? (
              <div
                ref={wrapRef}
                className="pv-img-wrap"
                style={{ cursor: 'default' }}
                onMouseEnter={() => setHovered(true)}
                onMouseLeave={() => setHovered(false)}
              >
                <img
                  ref={imgRef}
                  className={'pv-img' + (imgLoaded ? ' pv-img--loaded' : '')}
                  src={imgUrl}
                  alt={photo.PhotoDescription || ''}
                  onLoad={handleImgLoad}
                  onError={() => setImgLoaded(true)}
                />
                {(hovered || hoveredName) && imgLoaded && annBoxes.map((ann, i) => {
                  const { left, top, width: bw, height: bh } = ann.box;
                  const bk = Math.max(6, Math.min(10, bw * 0.2, bh * 0.2));
                  const isThisHovered = hoveredAnn === i || (hoveredName && ann.name === hoveredName);
                  const stroke = 'rgba(237,232,223,0.65)';
                  const dispLeft  = isThisHovered ? left - bw * 0.125 : left;
                  const dispWidth = isThisHovered ? bw * 1.25 : bw;
                  const S = bk + 3;
                  const corners = [
                    { pos: { top: 0,    left:  0 }, d: `M${bk+1} 1 L1 1 L1 ${bk+1}` },
                    { pos: { top: 0,    right: 0 }, d: `M1 1 L${bk+1} 1 L${bk+1} ${bk+1}` },
                    { pos: { bottom: 0, left:  0 }, d: `M${bk+1} ${bk+1} L1 ${bk+1} L1 1` },
                    { pos: { bottom: 0, right: 0 }, d: `M1 ${bk+1} L${bk+1} ${bk+1} L${bk+1} 1` },
                  ];
                  return (
                    <div key={i}
                      style={{ position: 'absolute', left: dispLeft, top, width: dispWidth, minHeight: bh, cursor: ann.name ? 'pointer' : 'default' }}
                      onMouseEnter={() => setHoveredAnn(i)}
                      onMouseLeave={() => setHoveredAnn(null)}
                      onClick={(e) => { e.stopPropagation(); if (ann.name) setPersonModal(ann.name); }}
                    >
                      {corners.map((c, k) => (
                        <svg key={k} style={{ position: 'absolute', ...c.pos, pointerEvents: 'none' }} width={S} height={S}>
                          <path d={c.d} fill="none" stroke={stroke} strokeWidth="1.5" />
                        </svg>
                      ))}
                      <div style={{ height: bh }} />
                      {isThisHovered && ann.name && (
                        <div style={{
                          background: 'rgba(5,5,5,0.85)', fontFamily: 'var(--mono)', fontSize: 8,
                          letterSpacing: '0.07em', color: 'var(--not-fg-dim)',
                          textAlign: 'center', padding: '3px 5px',
                          whiteSpace: 'normal', wordBreak: 'break-word',
                          lineHeight: 1.6, textTransform: 'uppercase', pointerEvents: 'none',
                        }}>{ann.name}</div>
                      )}
                    </div>
                  );
                })}
              </div>
            ) : (
              <div className="pv-img-placeholder mono">NO IMAGE AVAILABLE</div>
            )}
          </div>

          {/* Meta column */}
          <div className="pv-meta-col">
            {photo.PhotoDescription && (
              <div className="pv-caption">{photo.PhotoDescription}</div>
            )}

            {true && (
              <div className="pv-details mono">
                <span>{(photo.PhotoDate && photo.PhotoDate !== 'Unknown' ? photo.PhotoDate : 'Unknown Date')} · {locationLabel}</span>
                {photo.PhotoSource && (
                  <a href={photo.PhotoSource} target="_blank" rel="noopener noreferrer" className="pv-source-link">
                    {photo.PhotoSourceDesc || 'Source'} ↗
                  </a>
                )}
              </div>
            )}

            {people.length > 0 && (
              <div>
                <div className="pv-people-label mono">PEOPLE IN PHOTO</div>
                {people.map((p, i) => {
                  const pname = p.Name || p.name;
                  return (
                    <div key={i}
                      className="pv-person pv-person--clickable"
                      onClick={() => { if (pname) setPersonModal(pname); }}
                      onMouseEnter={() => { if (pname) setHoveredName(pname); }}
                      onMouseLeave={() => setHoveredName(null)}
                    >{pname}</div>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      </div>

      {personModal && (
        <PersonModal
          name={personModal}
          onClose={() => setPersonModal(null)}
          onSelectPerson={setPersonModal}
        />
      )}
    </div>
  );
}

// ─── Main Projection component ────────────────────────────────
function Projection({ speed = 1, mode = 'original', showCounter = true, showNav = true, seedPerson = null, autoPathfind = null }) {
  const wrapRef = React.useRef(null);
  const [vw, setVw] = React.useState(1440);
  const [queryOpen, setQueryOpen] = React.useState(false);
  const [matchResult, setMatchResult] = React.useState(null);
  // Shared person selections — lifted so NamesBrowser can populate QueryConsole inputs
  const [personA, setPersonA] = React.useState('');
  const [personB, setPersonB] = React.useState('');
  const [browserOpen, setBrowserOpen] = React.useState(false);
  const [pausedPhoto, setPausedPhoto] = React.useState(null); // photo clicked in filmstrip
  const [networkSeed, setNetworkSeed] = React.useState(null); // photo object | null
  const [speedMulti, setSpeedMulti] = React.useState(speed);
  const [isTabHidden, setIsTabHidden] = React.useState(
    typeof document !== 'undefined' ? document.visibilityState === 'hidden' : false
  );
  const [idlePaused, setIdlePaused] = React.useState(false);

  // Real photo chain
  const { chainRef, chainLen, extend } = usePhotoChain({ seedPerson, autoPathfind });
  // Stores parsed annotation data after each image loads: photoId → [{name, x, y, r}]
  const imageDataRef = React.useRef({});
  // Stores image aspect ratios (width/height) after each image loads
  const imageArRef = React.useRef({});

  const handleImageData = React.useCallback((photoId, people, ar) => {
    imageDataRef.current[photoId] = people;
    if (ar) imageArRef.current[photoId] = ar;
  }, []);

  // Returns the display width for a photo: cardH × AR, defaulting to CARD_W if not yet loaded
  const getCardW = (photoId) => {
    const ar = imageArRef.current[photoId];
    return ar ? Math.round(cardH * ar) : CARD_W;
  };

  React.useEffect(() => {
    const measure = () => {
      const el = wrapRef.current;
      if (el) setVw(el.clientWidth);
    };
    measure();
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, []);

  const ambientWatching = !queryOpen && !matchResult && !browserOpen && !networkSeed && !pausedPhoto;

  React.useEffect(() => {
    const onVisibilityChange = () => {
      const hidden = document.visibilityState === 'hidden';
      setIsTabHidden(hidden);
      if (!hidden) setIdlePaused(false);
    };

    document.addEventListener('visibilitychange', onVisibilityChange);
    onVisibilityChange();
    return () => document.removeEventListener('visibilitychange', onVisibilityChange);
  }, []);

  React.useEffect(() => {
    if (isTabHidden || idlePaused || !ambientWatching) return;

    let timeoutId;
    const armIdleTimer = () => {
      window.clearTimeout(timeoutId);
      timeoutId = window.setTimeout(() => setIdlePaused(true), HOMEPAGE_IDLE_TIMEOUT_MS);
    };

    const activityEvents = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'];
    activityEvents.forEach(eventName => {
      window.addEventListener(eventName, armIdleTimer, { passive: true });
    });
    armIdleTimer();

    return () => {
      window.clearTimeout(timeoutId);
      activityEvents.forEach(eventName => {
        window.removeEventListener(eventName, armIdleTimer);
      });
    };
  }, [isTabHidden, idlePaused, ambientWatching]);

  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {
        if (networkSeed) { setNetworkSeed(null); return; }
        if (pausedPhoto) { setPausedPhoto(null); return; }
        if (browserOpen) { setBrowserOpen(false); return; }
        if (matchResult) { setMatchResult(null); return; }
        setQueryOpen(false);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [networkSeed, pausedPhoto, browserOpen, matchResult]);

  // Sync ?match= URL param with current match result
  React.useEffect(() => {
    const url = new URL(window.location.href);
    if (matchResult) {
      const slugA = matchResult.slug1 || toMatchSlug(personA);
      const slugB = matchResult.slug2 || toMatchSlug(personB);
      if (slugA && slugB) {
        url.searchParams.set('match', `${slugA}/${slugB}`);
        window.history.pushState({}, '', url.toString());
      }
    } else {
      if (url.searchParams.has('match')) {
        url.searchParams.delete('match');
        window.history.replaceState({}, '', url.toString());
      }
    }
  }, [matchResult, personA, personB]);

  const streamPaused = !!pausedPhoto || isTabHidden || idlePaused;
  const xRef = useStreamX(speedMulti, streamPaused);
  const x = xRef.current;

  const stageHeight = 520;
  const cardH = CARD_W * 1.25;
  const cardTop = 40;

  const offset = x;

  // Build per-chain-index cumulative x positions so each card is exactly as wide
  // as its natural aspect ratio at full card height (no cropping).
  const chainPositions = [];
  let loopWidth = 0;
  if (chainLen > 0) {
    for (let i = 0; i < chainLen; i++) {
      chainPositions.push(loopWidth);
      const entry = chainRef.current[i];
      const w = entry ? getCardW(entry.photo.PhotoID) : CARD_W;
      loopWidth += w + CARD_GAP;
    }
  }
  if (loopWidth === 0) loopWidth = CARD_STRIDE;

  // Approximate last visible chain index for pre-fetch trigger
  const avgStride = chainLen > 0 ? loopWidth / chainLen : CARD_STRIDE;
  const approxLastSlot = Math.ceil((offset + vw) / avgStride) + 1;

  // Pre-fetch when the visible window is close to the chain end
  React.useEffect(() => {
    if (streamPaused) return;
    if (chainLen === 0 || approxLastSlot + CHAIN_PREFETCH > chainLen) {
      extend();
    }
  }, [chainLen, approxLastSlot, extend, streamPaused]);

  // Build visible slots by iterating over adjacent loop repetitions × chain indices.
  // Each slot carries its actual card width so downstream rendering is consistent.
  const visible = [];
  if (chainLen > 0) {
    const loopNum = Math.floor(offset / loopWidth);
    for (let loop = loopNum - 1; loop <= loopNum + 1; loop++) {
      for (let i = 0; i < chainLen; i++) {
        const entry = chainRef.current[i];
        if (!entry) continue;
        const cardW = getCardW(entry.photo.PhotoID);
        const left = loop * loopWidth + chainPositions[i] - offset;
        if (left + cardW < -120 || left > vw + 120) continue;
        visible.push({ slot: loop * chainLen + i, entry, left, cardW });
      }
    }
    visible.sort((a, b) => a.slot - b.slot);
  }

  // Find featured (center) photo for caption
  const centerX = vw / 2;
  let featured = visible[0] || null;
  let minD = Infinity;
  for (const v of visible) {
    const mid = v.left + v.cardW / 2;
    const d = Math.abs(mid - centerX);
    if (d < minD) { minD = d; featured = v; }
  }

  // Build connection data between adjacent visible cards
  // Each chain entry has .outboundName = person linking it to the next entry.
  const connections = [];
  for (let i = 0; i < visible.length - 1; i++) {
    const a = visible[i], b = visible[i + 1];
    const aIdx = ((a.slot % chainLen) + chainLen) % chainLen;
    const sharedName = chainRef.current[aIdx]?.outboundName;
    if (!sharedName) continue;

    // Use parsed annotation positions when available, fall back to card center
    const aData = imageDataRef.current[a.entry.photo.PhotoID];
    const bData = imageDataRef.current[b.entry.photo.PhotoID];
    const pa = aData?.find(p => p.name === sharedName);
    const pb = bData?.find(p => p.name === sharedName);

    const aW = a.cardW, bW = b.cardW;
    const fallbackY = cardTop + cardH * 0.45;
    connections.push({
      name: sharedName,
      ax: pa ? a.left + pa.x * aW : a.left + aW,
      ay: pa ? cardTop + pa.y * cardH : fallbackY,
      bx: pb ? b.left + pb.x * bW : b.left,
      by: pb ? cardTop + pb.y * cardH : fallbackY,
      boxW:  pa ? aW * pa.r * 2 * 1.6 : 0,
      boxWB: pb ? bW * pb.r * 2 * 1.6 : 0,
      pa, pb,
      key: a.slot + '→' + b.slot,
    });
  }

  const cur = featured?.entry;

  return (
    <div
      className={'projection projection--stream' + (queryOpen ? ' query-open' : '') + (networkSeed ? ' network-open' : '')}
      style={{ position: 'absolute', inset: 0, background: 'var(--not-bg)', color: 'var(--not-fg)', overflow: 'hidden' }}
    >
      <Header showNav={showNav} />

      {/* Filmstrip */}
      <div ref={wrapRef} className="proj-stream" style={{ height: stageHeight }}>
        <div className="proj-stream-mask proj-stream-mask--left" />
        <div className="proj-stream-mask proj-stream-mask--right" />

        {/* SVG overlay: connection boxes + lines */}
        <svg
          className="proj-stream-rail"
          width={vw} height={stageHeight}
          viewBox={`0 0 ${vw} ${stageHeight}`}
          preserveAspectRatio="none"
        >
          {/* Per-card annotation boxes for shared people */}
          {visible.map((v, i) => {
            const photoId = v.entry.photo.PhotoID;
            const aIdx = ((v.slot % chainLen) + chainLen) % chainLen;
            const sharedWithNext = chainRef.current[aIdx]?.outboundName;
            const prevAIdx = ((( v.slot - 1) % chainLen) + chainLen) % chainLen;
            const sharedWithPrev = chainRef.current[prevAIdx]?.outboundName;

            const imgData = imageDataRef.current[photoId] || [];
            const toShow = [];
            if (sharedWithNext) {
              const p = imgData.find(pp => pp.name === sharedWithNext);
              if (p) toShow.push({ name: sharedWithNext, p });
            }
            if (sharedWithPrev && sharedWithPrev !== sharedWithNext) {
              const p = imgData.find(pp => pp.name === sharedWithPrev);
              if (p) toShow.push({ name: sharedWithPrev, p });
            }

            return toShow.map((b, j) => {
              const bw = v.cardW * b.p.r * 2 * 1.6;
              const bh = cardH   * b.p.r * 2 * 1.6;
              const bx = v.left + b.p.x * v.cardW - bw / 2;
              const by = cardTop + b.p.y * cardH  - bh / 2;
              return (
                <g key={v.slot + '-' + j}>
                  <rect x={bx} y={by} width={bw} height={bh}
                    fill="none" stroke="oklch(0.84 0.11 78)" strokeWidth="1.2"
                    opacity="0.95" shapeRendering="crispEdges" />
                  {[
                    [bx, by, bx+6, by, bx, by+6],
                    [bx+bw, by, bx+bw-6, by, bx+bw, by+6],
                    [bx, by+bh, bx+6, by+bh, bx, by+bh-6],
                    [bx+bw, by+bh, bx+bw-6, by+bh, bx+bw, by+bh-6],
                  ].map(([tx,ty,x2,y2,x3,y3], k) => (
                    <path key={k} d={`M${x2} ${y2} L${tx} ${ty} L${x3} ${y3}`}
                      fill="none" stroke="oklch(0.86 0.13 78)" strokeWidth="1.6" />
                  ))}
                </g>
              );
            });
          })}

          {/* Inter-photo connection lines */}
          {connections.map(c => {
            const startX = c.pa ? c.ax + c.boxW / 2  : c.ax;
            const endX   = c.pb ? c.bx - c.boxWB / 2 : c.bx;
            const midX = (startX + endX) / 2;
            const pathD = `M ${startX} ${c.ay} C ${midX} ${c.ay}, ${midX} ${c.by}, ${endX} ${c.by}`;
            return (
              <g key={c.key}>
                <path d={pathD} fill="none" stroke="oklch(0.82 0.11 78)"
                  strokeWidth="1" strokeDasharray="3 3" opacity="0.85" />
                <circle cx={startX} cy={c.ay} r="2.2" fill="oklch(0.88 0.13 78)" />
                <circle cx={endX}   cy={c.by} r="2.2" fill="oklch(0.88 0.13 78)" />
                <text x={midX} y={(c.ay + c.by) / 2 - 10} textAnchor="middle"
                  fill="oklch(0.88 0.13 78)"
                  style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase' }}
                >{c.name.toUpperCase()}</text>
              </g>
            );
          })}
        </svg>

        {/* Photo cards with real images */}
        {visible.map(v => {
          const { photo, inboundName, outboundName } = v.entry;
          const year = photo.Year || photo.year || '';
          const location = formatPhotoLocation(photo.Location || photo.location, '') || photo.PhotoDescription || '';
          return (
            <div key={v.slot} className="proj-card"
              style={{ left: v.left, top: cardTop, width: v.cardW, height: cardH, cursor: 'pointer' }}
              onClick={() => setPausedPhoto({ photo, inboundName, outboundName })}>
              <RealPhotoCard
                photo={photo}
                mode={mode}
                cardH={cardH}
                onImageData={handleImageData}
              />
              <div className="proj-card-meta mono">
                <span>{year}</span>
                <span className="proj-card-loc">{location}</span>
              </div>
            </div>
          );
        })}

        {/* Empty state while chain loads */}
        {chainLen === 0 && (
          <div style={{
            position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: 'var(--not-fg-fainter)', fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.2em',
          }}>LOADING ARCHIVE…</div>
        )}
      </div>

      {/* Featured caption */}
      {cur && <PhotoCaption photo={cur.photo} inboundName={cur.inboundName} outboundName={cur.outboundName} />}

      {idlePaused && !isTabHidden && ambientWatching && (
        <button
          type="button"
          className="proj-idle-resume mono"
          onClick={() => setIdlePaused(false)}
        >
          Continue watching
        </button>
      )}

      {/* Bottom console */}
      <div className="proj-console">
        <div className="proj-console-frame">
          {!queryOpen && (
            <div className="proj-ambient">
              <div className="proj-ambient-tag">
                <div className="tag-serif">All of our lives overlap in the Network of Time.</div>
                <div className="tag-sub mono">A live archive of documented human proximity.</div>
              </div>
              <div className="proj-ambient-actions">
                <button className="proj-cta proj-cta--secondary" onClick={() => setQueryOpen(true)}>
                  <span className="proj-cta-kicker mono">01</span>
                  <span className="proj-cta-label">Trace a connection
                    <svg width="16" height="12" viewBox="0 0 18 12" fill="none">
                      <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1" />
                    </svg>
                  </span>
                </button>
                <div className="proj-cta-divider" />
                <a href="/odyssey" className="proj-cta proj-cta--primary" style={{ textDecoration: 'none' }}>
                  <span className="proj-cta-kicker mono">02</span>
                  <span className="proj-cta-label">Begin an odyssey
                    <svg width="16" height="12" viewBox="0 0 18 12" fill="none">
                      <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1" />
                    </svg>
                  </span>
                </a>
                <div className="proj-cta-divider" />
                <button
                  className="proj-cta proj-cta--network"
                  onClick={() => { if (cur) setNetworkSeed(cur.photo); }}
                  disabled={!cur}
                >
                  <span className="proj-cta-kicker mono">03</span>
                  <span className="proj-cta-label">View network
                    <svg width="16" height="12" viewBox="0 0 18 12" fill="none">
                      <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1" />
                    </svg>
                  </span>
                </button>
              </div>
              <div className="proj-ambient-meta mono">
                {showCounter && (
                  <div className="proj-archive-stats">
                    <span>{STATS.people} people</span>
                    <span>{STATS.photos} photos</span>
                  </div>
                )}
                <div className="proj-speed-wrap">
                  <input
                    type="range"
                    className="proj-speed-slider"
                    min="0.5"
                    max="2"
                    step="0.05"
                    value={speedMulti}
                    style={{ '--pct': ((speedMulti - 0.5) / 1.5 * 100).toFixed(1) + '%' }}
                    onChange={e => setSpeedMulti(parseFloat(e.target.value))}
                    aria-label="Filmstrip speed"
                  />
                  <span className={'proj-speed-label' + (speedMulti !== 1 ? ' proj-speed-label--active' : '')}>
                    {speedMulti === 1 ? '1×' : speedMulti.toFixed(1) + '×'}
                  </span>
                </div>
              </div>
            </div>
          )}
          {queryOpen && (
            <QueryConsole
              onClose={() => { setQueryOpen(false); setMatchResult(null); }}
              onMatchResult={setMatchResult}
              personA={personA} setPersonA={setPersonA}
              personB={personB} setPersonB={setPersonB}
              onBrowse={() => setBrowserOpen(true)}
            />
          )}
        </div>
      </div>

      {/* Names browser overlay — rendered inside .projection so it covers the slideshow */}
      {browserOpen && (
        <NamesBrowser
          personA={personA} setPersonA={setPersonA}
          personB={personB} setPersonB={setPersonB}
          onClose={() => setBrowserOpen(false)}
          onTrace={() => {
            setBrowserOpen(false);
            // QueryConsole is already open with the pre-filled values
          }}
        />
      )}

      {/* Path view — full-screen cinematic overlay when match result exists */}
      {matchResult && (
        <PathView
          result={matchResult}
          nameA={personA}
          nameB={personB}
          onBack={() => setMatchResult(null)}
          onClose={() => { setMatchResult(null); setQueryOpen(false); }}
        />
      )}

      {/* Filmstrip photo detail — shown when a card is clicked */}
      {pausedPhoto && (
        <PhotoDetailOverlay
          photo={pausedPhoto.photo}
          inboundName={pausedPhoto.inboundName}
          outboundName={pausedPhoto.outboundName}
          onClose={() => setPausedPhoto(null)}
        />
      )}

      {/* Network graph overlay */}
      {networkSeed && (
        <NetworkGraphView
          seedPhoto={networkSeed}
          onClose={() => setNetworkSeed(null)}
        />
      )}
    </div>
  );
}

// ─── Query console ────────────────────────────────────────────
function QueryConsole({ onClose, onMatchResult, personA, setPersonA, personB, setPersonB, onBrowse }) {
  const [focused, setFocused] = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);
  const emptySuggestionState = { people: [], total: 0, offset: 0, hasMore: false, loadingMore: false };
  const [richSuggestions, setRichSuggestions] = React.useState({
    a: emptySuggestionState,
    b: emptySuggestionState,
  });
  const suggestionPageSize = 50;

  React.useEffect(() => {
    const query = focused === 'a' ? personA : focused === 'b' ? personB : '';
    if (!focused || !query || query.trim().length < 2) {
      if (focused) setRichSuggestions(prev => ({ ...prev, [focused]: emptySuggestionState }));
      return;
    }

    const field = focused;
    const controller = new AbortController();
    const timeout = setTimeout(async () => {
      try {
        const params = new URLSearchParams({ q: query.trim(), limit: String(suggestionPageSize), offset: '0' });
        const resp = await fetch('/api/people?' + params.toString(), {
          signal: controller.signal,
          headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
        });
        if (!resp.ok) throw new Error('Suggestion lookup failed');
        const data = await resp.json();
        const people = data.people || [];
        setRichSuggestions(prev => ({
          ...prev,
          [field]: {
            people,
            total: data.total || people.length,
            offset: people.length,
            hasMore: !!data.hasMore,
            loadingMore: false,
          },
        }));
      } catch (err) {
        if (err.name === 'AbortError') return;
        const fallback = ROSTER
          .filter(n => n.toLowerCase().includes(query.toLowerCase()))
          .slice(0, 200)
          .map(name => ({ name }));
        setRichSuggestions(prev => ({
          ...prev,
          [field]: {
            people: fallback,
            total: fallback.length,
            offset: fallback.length,
            hasMore: false,
            loadingMore: false,
          },
        }));
      }
    }, 180);

    return () => {
      clearTimeout(timeout);
      controller.abort();
    };
  }, [focused, personA, personB]);

  const suggestionsFor = (field) => (focused === field ? richSuggestions[field].people : []);

  const loadMoreSuggestions = async (field) => {
    const query = field === 'a' ? personA : personB;
    const current = richSuggestions[field];
    if (!query || query.trim().length < 2 || !current.hasMore || current.loadingMore) return;

    setRichSuggestions(prev => ({
      ...prev,
      [field]: { ...prev[field], loadingMore: true },
    }));

    try {
      const params = new URLSearchParams({
        q: query.trim(),
        limit: String(suggestionPageSize),
        offset: String(current.offset),
      });
      const resp = await fetch('/api/people?' + params.toString(), {
        headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
      });
      if (!resp.ok) throw new Error('Suggestion lookup failed');
      const data = await resp.json();
      const nextPeople = data.people || [];

      setRichSuggestions(prev => {
        const seen = new Set(prev[field].people.map(p => p.id || p.name));
        const merged = [
          ...prev[field].people,
          ...nextPeople.filter(p => !seen.has(p.id || p.name)),
        ];

        return {
          ...prev,
          [field]: {
            people: merged,
            total: data.total || merged.length,
            offset: merged.length,
            hasMore: !!data.hasMore,
            loadingMore: false,
          },
        };
      });
    } catch (err) {
      setRichSuggestions(prev => ({
        ...prev,
        [field]: { ...prev[field], loadingMore: false, hasMore: false },
      }));
    }
  };

  const canSubmit = personA.trim() && personB.trim() && !loading;

  const handleEngage = async () => {
    if (!canSubmit) return;
    setLoading(true); setError(null); onMatchResult(null);
    try {
      const resp = await fetch('/api/match', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'xsrf-token': getCsrfToken(), 'X-Requested-With': 'XMLHttpRequest', 'X-Application-Request': 'true' },
        body: JSON.stringify({ first: personA.trim(), last: personB.trim() }),
      });
      const data = await resp.json();
      if (!resp.ok) {
        setError(data.error || data.message || 'Query failed. Please try again.');
      } else if (!data.photos || data.photos.length === 0) {
        setError('No connection found between these two people in our archive.');
      } else {
        onMatchResult(data);
      }
    } catch (e) {
      setError('Connection error. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  const fillExample = (nameA, nameB) => {
    setPersonA(nameA); setPersonB(nameB); onMatchResult(null); setError(null);
  };

  const pickRandom = (exclude) => {
    if (ROSTER.length === 0) return '';
    let name, tries = 0;
    do { name = ROSTER[Math.floor(Math.random() * ROSTER.length)]; tries++; }
    while (name === exclude && ROSTER.length > 1 && tries < 20);
    return name;
  };

  const remaining = window.REMAINING_QUERIES;
  const remainingLabel = remaining === 'unlimited' ? null :
    (remaining <= 0
      ? 'Query limit reached — sign in for more'
      : `${remaining} free ${remaining === 1 ? 'query' : 'queries'} remaining`);

  return (
    <div className="qc" role="dialog" aria-label="Trace a connection">
      <div className="qc-head">
        <div className="qc-head-left mono">
          <span className="qc-dot" />
          <span>QUERY MODE · ENGAGE</span>
        </div>
        <button className="qc-close mono" onClick={onClose} aria-label="Close">
          <span>ESC</span>
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
            <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
          </svg>
        </button>
      </div>

      <div className="qc-prompt mono">Choose two people. Network of Time will search for a chain of photographs connecting them.</div>

      <div className="qc-row">
        <QCField label="PERSON 01" placeholder="e.g. Marilyn Monroe" value={personA}
          onChange={(v) => { setPersonA(v); onMatchResult(null); setError(null); }}
          suggestions={suggestionsFor('a')}
          hasMore={focused === 'a' && richSuggestions.a.hasMore}
          loadingMore={focused === 'a' && richSuggestions.a.loadingMore}
          onLoadMore={() => loadMoreSuggestions('a')}
          onFocus={() => setFocused('a')}
          onBlur={() => setTimeout(() => setFocused(f => f === 'a' ? null : f), 150)}
          onPick={(n) => { setPersonA(n); setFocused(null); }}
          onRandom={() => { const n = pickRandom(personB); setPersonA(n); onMatchResult(null); setError(null); }} />

        <div className="qc-via">
          <div className="qc-via-line" />
          <div className="qc-via-label mono">via</div>
          <div className="qc-via-line" />
        </div>

        <QCField label="PERSON 02" placeholder="e.g. Madonna" value={personB}
          onChange={(v) => { setPersonB(v); onMatchResult(null); setError(null); }}
          suggestions={suggestionsFor('b')}
          hasMore={focused === 'b' && richSuggestions.b.hasMore}
          loadingMore={focused === 'b' && richSuggestions.b.loadingMore}
          onLoadMore={() => loadMoreSuggestions('b')}
          onFocus={() => setFocused('b')}
          onBlur={() => setTimeout(() => setFocused(f => f === 'b' ? null : f), 150)}
          onPick={(n) => { setPersonB(n); setFocused(null); }}
          onRandom={() => { const n = pickRandom(personA); setPersonB(n); onMatchResult(null); setError(null); }} />

        <button
          className={'qc-engage' + (canSubmit ? ' qc-engage--ready' : '')}
          disabled={!canSubmit}
          onClick={handleEngage}
        >
          {loading
            ? <span>Searching…</span>
            : <><span>Engage</span>
              <svg width="14" height="10" viewBox="0 0 18 12" fill="none">
                <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1.2" />
              </svg></>
          }
        </button>
      </div>

      {error && (
        <div className="mono" style={{ fontSize: 10, color: 'oklch(0.65 0.15 25)', letterSpacing: '0.14em', marginTop: 12, paddingTop: 10, borderTop: '1px dashed var(--not-rule)' }}>
          {error.toUpperCase()}
        </div>
      )}

      <div className="qc-foot mono">
        <span className="qc-foot-label">TRY</span>
        <button className="qc-chip" onClick={() => fillExample('Dwight D. Eisenhower', 'Madonna')}>
          Dwight D. Eisenhower ↔ Madonna
        </button>
        <button className="qc-chip" onClick={() => fillExample('Marilyn Monroe', 'Bob Dylan')}>
          Marilyn Monroe ↔ Bob Dylan
        </button>
        <button className="qc-chip" onClick={() => fillExample('Nikita Khrushchev', 'Lou Reed')}>
          Khrushchev ↔ Lou Reed
        </button>
        {remainingLabel && (
          <span style={{ color: remainingLabel.includes('limit') ? 'oklch(0.65 0.12 45)' : 'var(--not-fg-fainter)' }}>
            {remainingLabel}
          </span>
        )}
      </div>

      <button className="qc-browse-btn mono" onClick={onBrowse}>
        <span>Browse the full index of names</span>
        <svg width="14" height="10" viewBox="0 0 18 12" fill="none">
          <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1.2" />
        </svg>
      </button>
    </div>
  );
}

// ─── Path view — cinematic full-screen match result ───────────
function PathView({ result, nameA, nameB, onBack, onClose }) {
  const photos = result.photos || [];
  const steps = photos.length;
  const [step, setStep] = React.useState(0);
  const [overviewOpen, setOverviewOpen] = React.useState(false);
  const [imgLoaded, setImgLoaded] = React.useState(false);
  const [annBoxes, setAnnBoxes] = React.useState([]);  // [{name, isBridge, box:{left,top,w,h}}]
  const [hovered, setHovered] = React.useState(false);
  const [hoveredAnn, setHoveredAnn] = React.useState(null); // index of ann box cursor is over
  const [hoveredName, setHoveredName] = React.useState(null); // name hovered in people list
  const [personModal, setPersonModal] = React.useState(null); // person name string or null
  const personModalRef = React.useRef(null);  // ref so ESC handler can read without re-registering
  const imgRef  = React.useRef(null);
  const wrapRef = React.useRef(null);

  // Keep ref in sync with state for keyboard handler
  React.useEffect(() => { personModalRef.current = personModal; }, [personModal]);

  const cur = photos[step] || {};
  const curLocation = formatPhotoLocation(cur.location, '');
  // meta = photo.Meta in Neo4j = the Cloudflare image URL (same as PhotoMeta in filmstrip)
  const imgUrl = cur.meta || (cur.embedlink && cur.embedlink !== 'N/A' ? cur.embedlink : null);
  const hasImage = !!imgUrl;

  // Bridge: last person in current photo's path-segment people array
  const outbound = cur.people && cur.people.length > 0
    ? cur.people[cur.people.length - 1]
    : null;

  const isFirst = step === 0;
  const isLast = step === steps - 1;

  // Keyboard navigation
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
        e.stopPropagation();
        setStep(s => Math.min(s + 1, steps - 1));
      } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
        e.stopPropagation();
        setStep(s => Math.max(s - 1, 0));
      } else if (e.key === 'Escape') {
        e.stopPropagation();
        if (personModalRef.current) { setPersonModal(null); return; }
        onBack();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [steps, onBack]);

  // Reset per-step state when step changes (keep hovered — wrap persists, mouseenter/leave owns it)
  React.useEffect(() => {
    setImgLoaded(false);
    setAnnBoxes([]);
    setHoveredAnn(null);
    setHoveredName(null);
  }, [step]);

  // Preload adjacent images
  React.useEffect(() => {
    [photos[step + 1], photos[step - 1]].forEach(p => {
      const url = p?.meta || (p?.embedlink && p.embedlink !== 'N/A' ? p.embedlink : null);
      if (url) { const img = new Image(); img.src = url; }
    });
  }, [step, photos]);

  // Called when the <img> finishes loading — parse annotations and compute display boxes
  const handleImgLoad = () => {
    setImgLoaded(true);
    const img  = imgRef.current;
    const wrap = wrapRef.current;
    if (!img || !wrap) return;

    const natW = img.naturalWidth  || 1;
    const natH = img.naturalHeight || 1;
    // Rendered image size (constrained by max-width/max-height within the wrap)
    const rendW = img.clientWidth;
    const rendH = img.clientHeight;
    // Centering offset: the wrap is larger than the rendered image (flex-centered)
    const offX = (wrap.clientWidth  - rendW) / 2;
    const offY = (wrap.clientHeight - rendH) / 2;

    // People in the path segment for this photo → highlighted in blue
    const bridgeNames = new Set((cur.people || []).map(p => p.name).filter(Boolean));
    const bridgeIds   = new Set((cur.people || []).map(p => p.personId).filter(Boolean));

    // parseAnnotations expects capital-A Annotations
    const rawAnns = parseAnnotations({ Annotations: cur.annotations });
    const boxes = rawAnns.map(ann => {
      const box = extractBox(ann, natW, natH, rendW, rendH);
      if (!box) return null;
      const name = ann.personName || ann.name || '';
      const pid  = ann.personId   || ann.personID || ann.PersonID || '';
      const isBridge = bridgeNames.has(name) || (pid && bridgeIds.has(pid));
      return {
        name,
        isBridge,
        box: { left: offX + box.left, top: offY + box.top, width: box.width, height: box.height },
      };
    }).filter(Boolean);

    setAnnBoxes(boxes);
  };

  const goTo = (i) => { setStep(i); setOverviewOpen(false); };

  return (
    <div className="pv-backdrop">
      {/* Header */}
      <div className="pv-header">
        <div className="pv-status mono">
          <span className="pv-status-dot" />
          CONNECTION FOUND · {steps} {steps === 1 ? 'STEP' : 'STEPS'}
        </div>
        <div className="pv-names">
          <span className="pv-name-a">{nameA}</span>
          <span className="pv-dash mono"> ─── </span>
          <span className="pv-name-b">{nameB}</span>
        </div>
        <div className="pv-hdr-right">
          <button className="pv-btn mono" onClick={() => setOverviewOpen(o => !o)}>
            {overviewOpen ? 'hide overview' : 'chain overview'}
          </button>
          <button className="pv-btn mono" onClick={onClose} aria-label="Return to odyssey">
            <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
              <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
            </svg>
          </button>
        </div>
      </div>

      {/* Stage: chain overview or step-by-step view */}
      {overviewOpen
        ? <ChainOverview photos={photos} step={step} onGoTo={goTo} />
        : (
          <div className="pv-stage">
            {/* Image column */}
            <div className="pv-image-col">
              {hasImage ? (
                <div
                  ref={wrapRef}
                  className="pv-img-wrap"
                  onMouseEnter={() => setHovered(true)}
                  onMouseLeave={() => setHovered(false)}
                >
                  <img
                    ref={imgRef}
                    key={step}
                    className={'pv-img' + (imgLoaded ? ' pv-img--loaded' : '')}
                    src={imgUrl}
                    alt={cur.desc || ''}
                    onLoad={handleImgLoad}
                    onError={() => setImgLoaded(true)}
                  />
                  {/* Annotation overlays — visible on image hover or name-list hover */}
                  {(hovered || hoveredName) && imgLoaded && annBoxes.map((ann, i) => {
                    const { left, top, width: bw, height: bh } = ann.box;
                    const bk = Math.max(6, Math.min(10, bw * 0.2, bh * 0.2)); // bracket arm px
                    const isThisHovered = hoveredAnn === i || (hoveredName && ann.name === hoveredName);
                    const stroke = ann.isBridge ? '#5aabff' : 'rgba(237,232,223,0.65)';
                    // Expand 25% wider (centred) when this box is hovered
                    const dispLeft  = isThisHovered ? left - bw * 0.125 : left;
                    const dispWidth = isThisHovered ? bw * 1.25 : bw;
                    const S = bk + 3; // mini-SVG canvas size
                    // Four independent corner brackets — each pinned to a corner of the div,
                    // so they automatically follow height as the div grows.
                    const corners = [
                      { pos: { top: 0,    left:  0 }, d: `M${bk+1} 1 L1 1 L1 ${bk+1}` },
                      { pos: { top: 0,    right: 0 }, d: `M1 1 L${bk+1} 1 L${bk+1} ${bk+1}` },
                      { pos: { bottom: 0, left:  0 }, d: `M${bk+1} ${bk+1} L1 ${bk+1} L1 1` },
                      { pos: { bottom: 0, right: 0 }, d: `M1 ${bk+1} L${bk+1} ${bk+1} L${bk+1} 1` },
                    ];
                    return (
                      <div key={i}
                        style={{ position: 'absolute', left: dispLeft, top, width: dispWidth, minHeight: bh, cursor: ann.name ? 'pointer' : 'default' }}
                        onMouseEnter={() => setHoveredAnn(i)}
                        onMouseLeave={() => setHoveredAnn(null)}
                        onClick={(e) => { e.stopPropagation(); if (ann.name) setPersonModal(ann.name); }}
                      >
                        {/* Corner SVGs — absolutely pinned so they track the div's expanding height */}
                        {corners.map((c, k) => (
                          <svg key={k} style={{ position: 'absolute', ...c.pos, pointerEvents: 'none' }} width={S} height={S}>
                            <path d={c.d} fill="none" stroke={stroke} strokeWidth="1.5" />
                          </svg>
                        ))}
                        {/* Normal-flow spacer: occupies the original face-box height */}
                        <div style={{ height: bh }} />
                        {/* Name label — normal flow so it pushes the div (and bottom brackets) down */}
                        {isThisHovered && ann.name && (
                          <div style={{
                            background: 'rgba(5,5,5,0.85)',
                            fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '0.07em',
                            color: ann.isBridge ? '#5aabff' : 'var(--not-fg-dim)',
                            textAlign: 'center', padding: '3px 5px',
                            whiteSpace: 'normal', wordBreak: 'break-word',
                            lineHeight: 1.6, textTransform: 'uppercase',
                            pointerEvents: 'none',
                          }}>
                            {ann.name}
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              ) : (
                <div className="pv-img-placeholder mono">NO IMAGE AVAILABLE</div>
              )}
            </div>

            {/* Meta column */}
            <div className="pv-meta-col">
              <div className="pv-step-label mono">STEP {step + 1} OF {steps}</div>

              {cur.desc && (
                <div className="pv-caption">{cur.desc}</div>
              )}

              {(cur.date || curLocation || cur.source) && (
                <div className="pv-details mono">
                  {(cur.date || curLocation) && (
                    <span>{[cur.date, curLocation].filter(Boolean).join(' · ')}</span>
                  )}
                  {cur.source && (
                    <a href={cur.source} target="_blank" rel="noopener noreferrer" className="pv-source-link">
                      {cur.sourcedesc || 'Source'} ↗
                    </a>
                  )}
                </div>
              )}

              {cur.allPeople && cur.allPeople.length > 0 && (
                <div>
                  <div className="pv-people-label mono">PEOPLE IN PHOTO</div>
                  {cur.allPeople.map((p, i) => {
                    const isBridge = outbound && (p.name === outbound.name || p.personId === outbound.personId);
                    const pname = p.name || p.Name;
                    return (
                      <div key={i}
                        className={'pv-person pv-person--clickable' + (isBridge ? ' pv-person--bridge' : '')}
                        onClick={() => { if (pname) setPersonModal(pname); }}
                        onMouseEnter={() => { if (pname) setHoveredName(pname); }}
                        onMouseLeave={() => setHoveredName(null)}
                      >
                        {pname}
                      </div>
                    );
                  })}
                </div>
              )}

              {/* Bridge connector — pinned to bottom of meta column */}
              {outbound && !isLast && (
                <div className="pv-bridge">
                  <div className="pv-bridge-label mono">CONNECTS VIA</div>
                  <div className="pv-bridge-name">{outbound.name} →</div>
                </div>
              )}
            </div>
          </div>
        )
      }

      {/* Navigation */}
      <div className="pv-nav">
        <button className="pv-nav-btn mono" onClick={() => goTo(step - 1)} disabled={isFirst} aria-label="Previous step">
          ← PREV
        </button>
        <div className="pv-dots">
          {photos.map((_, i) => (
            <button
              key={i}
              className={'pv-dot-btn' + (i === step ? ' pv-dot-btn--active' : '')}
              onClick={() => goTo(i)}
              aria-label={`Step ${i + 1}`}
            />
          ))}
        </div>
        <button className="pv-nav-btn mono" onClick={() => goTo(step + 1)} disabled={isLast} aria-label="Next step">
          NEXT →
        </button>
      </div>

      {/* Person detail modal — floats above everything in the path view */}
      {personModal && (
        <PersonModal
          name={personModal}
          onClose={() => setPersonModal(null)}
          onSelectPerson={(pname) => setPersonModal(pname)}
        />
      )}
    </div>
  );
}

// ─── Chain overview — scrollable thumbnail list ────────────────
function ChainOverview({ photos, step, onGoTo }) {
  return (
    <div className="pv-overview">
      {photos.map((photo, i) => {
        const imgUrl = photo.meta || (photo.embedlink && photo.embedlink !== 'N/A' ? photo.embedlink : null);
        const hasImage = !!imgUrl;
        const outbound = photo.people && photo.people.length > 0
          ? photo.people[photo.people.length - 1]
          : null;
        const isLast = i === photos.length - 1;
        return (
          <div
            key={i}
            className={'pv-ov-row' + (i === step ? ' pv-ov-row--active' : '')}
            onClick={() => onGoTo(i)}
          >
            <div className="pv-ov-step-num mono">STEP {i + 1}</div>
            {hasImage && (
              <img className="pv-ov-thumb" src={imgUrl} alt="" loading="lazy" />
            )}
            <div className="pv-ov-info">
              <div className="pv-ov-caption">{photo.guide || photo.desc || formatPhotoLocation(photo.location, '') || '—'}</div>
              {(photo.date || formatPhotoLocation(photo.location, '')) && (
                <div className="pv-ov-meta mono">
                  {[photo.date, formatPhotoLocation(photo.location, '')].filter(Boolean).join(' · ')}
                </div>
              )}
              {outbound && !isLast && (
                <div className="pv-ov-bridge mono">connects via {outbound.name} →</div>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ─── Person detail modal ───────────────────────────────────────
function PersonModal({ name, onClose, onSelectPerson }) {
  const [data, setData]         = React.useState(null);
  const [photos, setPhotos]     = React.useState([]);
  const [hasMore, setHasMore]   = React.useState(false);
  const [loading, setLoading]   = React.useState(true);
  const [loadingMore, setLoadingMore] = React.useState(false);
  const [error, setError]       = React.useState(null);
  const [selectedPhoto, setSelectedPhoto] = React.useState(null);

  const sortPhotos = (arr) =>
    [...arr].sort((a, b) => {
      if (!a.PhotoDate && !b.PhotoDate) return 0;
      if (!a.PhotoDate) return 1;
      if (!b.PhotoDate) return -1;
      return a.PhotoDate < b.PhotoDate ? -1 : 1;
    });

  React.useEffect(() => {
    setLoading(true); setError(null); setData(null); setPhotos([]);
    fetch(`/api/people/${encodeURIComponent(name)}/modal`)
      .then(r => r.ok ? r.json() : Promise.reject())
      .then(d => {
        setData(d);
        setPhotos(sortPhotos(d.photos || []));
        setHasMore(d.hasMorePhotos || false);
        setLoading(false);
      })
      .catch(() => { setError('Could not load person data.'); setLoading(false); });
  }, [name]);

  const loadMore = async (e) => {
    if (e) {
      e.preventDefault();
      e.stopPropagation();
    }
    if (loadingMore || !hasMore) return;
    setLoadingMore(true);
    try {
      const r = await fetch(`/api/people/${encodeURIComponent(name)}/photos?offset=${photos.length}&limit=10`);
      const d = await r.json();
      setPhotos(prev => sortPhotos([...prev, ...(d.photos || [])]));
      setHasMore(d.hasMore || false);
    } catch (_) {}
    setLoadingMore(false);
  };

  const openPhoto = (photo, e) => {
    if (e) {
      e.preventDefault();
      e.stopPropagation();
    }
    const people = photo.People || [];
    const hasSelectedPerson = people.some(p => (p.Name || p.name) === name);
    setSelectedPhoto({
      ...photo,
      PhotoMeta: photo.PhotoMeta || photo.EmbedLink || '',
      PhotoDescription: photo.PhotoDescription || photo.PhotoGuide || '',
      People: hasSelectedPerson ? people : [{ Name: name }, ...people],
    });
  };

  // co-appearing people sorted alphabetically
  const copeople = data ? Object.keys(data.photoCounts || {}).sort() : [];

  return (
    <div className="pm-backdrop" onClick={(e) => { e.stopPropagation(); if (e.target === e.currentTarget) onClose(); }}>
      <div className="pm-panel" onClick={(e) => e.stopPropagation()}>

        {/* Header */}
        <div className="pm-header">
          <div className="pm-header-left">
            <div className="pm-name">{name}</div>
            {data?.personWikipage && (
              <a className="pm-wiki mono" href={data.personWikipage} target="_blank" rel="noopener noreferrer">
                {data.personBiosource || 'Source'} →
              </a>
            )}
          </div>
          <button className="pv-btn mono" onClick={onClose} aria-label="Close">
            <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
              <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
            </svg>
          </button>
        </div>

        {/* Description */}
        {data?.personDescription && (
          <div className="pm-desc">{data.personDescription}</div>
        )}

        {loading && <div className="pm-status mono">loading…</div>}
        {error   && <div className="pm-status pm-status--err mono">{error}</div>}

        {/* Two-panel body */}
        {!loading && !error && data && (
          <div className="pm-body">

            {/* Photos panel */}
            <div className="pm-col">
              <div className="pm-col-label mono">
                PHOTOS · {data.totalPhotoCount}
              </div>
              <div className="pm-scroll">
                {photos.map((photo, i) => {
                  const imgSrc = photo.PhotoMeta || photo.EmbedLink || null;
                  return (
                    <button
                      key={photo.PhotoID || i}
                      type="button"
                      className="pm-photo-row pm-photo-row--clickable"
                      onClick={(e) => openPhoto(photo, e)}
                    >
                      {imgSrc && (
                        <img className="pm-photo-thumb" src={imgSrc} alt="" loading="lazy" />
                      )}
                      <div className="pm-photo-info">
                        <div className="pm-photo-caption">
                          {photo.PhotoGuide || photo.PhotoDescription || '—'}
                        </div>
                        {photo.PhotoDate && (
                          <div className="pm-photo-date mono">{photo.PhotoDate}</div>
                        )}
                      </div>
                    </button>
                  );
                })}
                {hasMore && (
                  <button type="button" className="pm-load-more mono" onClick={loadMore} disabled={loadingMore}>
                    {loadingMore ? 'loading…' : 'load more →'}
                  </button>
                )}
              </div>
            </div>

            {/* Co-appearing people panel */}
            <div className="pm-col">
              <div className="pm-col-label mono">
                APPEARS WITH · {copeople.length}
              </div>
              <div className="pm-scroll">
                {copeople.map((pname, i) => (
                  <div key={i} className="pm-coname pm-coname--link"
                    onClick={() => onSelectPerson(pname)}
                  >
                    {pname}
                  </div>
                ))}
              </div>
            </div>

          </div>
        )}
      </div>
      {selectedPhoto && (
        <PhotoDetailOverlay
          photo={selectedPhoto}
          inboundName={name}
          outboundName={null}
          onClose={() => setSelectedPhoto(null)}
        />
      )}
    </div>
  );
}

function QCField({ label, placeholder, value, onChange, suggestions, hasMore, loadingMore, onLoadMore, onFocus, onBlur, onPick, onRandom }) {
  const inputRef = React.useRef(null);

  const handleRandom = () => {
    if (!onRandom) return;
    onRandom();
    const el = inputRef.current;
    if (el) {
      el.classList.remove('qc-field-input--flash');
      void el.offsetWidth; // force reflow to restart animation
      el.classList.add('qc-field-input--flash');
      setTimeout(() => el.classList.remove('qc-field-input--flash'), 500);
    }
  };

  return (
    <div className="qc-field">
      <div className="qc-field-label mono">{label}</div>
      <input
        ref={inputRef}
        className="qc-field-input"
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onFocus={onFocus}
        onBlur={onBlur}
        autoComplete="off"
        spellCheck="false"
      />
      {onRandom && (
        <button className="qc-random-btn mono" onClick={handleRandom} tabIndex={-1}>
          random ↯
        </button>
      )}
      {suggestions.length > 0 && (
        <div
          className="qc-suggest"
          onScroll={(e) => {
            const el = e.currentTarget;
            if (hasMore && !loadingMore && el.scrollTop + el.clientHeight >= el.scrollHeight - 80) {
              onLoadMore();
            }
          }}
        >
          {suggestions.map(person => {
            const name = typeof person === 'string' ? person : person.name;
            const thumbnail = typeof person === 'string' ? null : person.thumbnail;
            const description = typeof person === 'string' ? null : person.description;
            const key = (typeof person === 'string' ? person : (person.id || person.name));

            return (
              <button key={key} className="qc-suggest-row" onMouseDown={(e) => { e.preventDefault(); onPick(name); }}>
                <span className="qc-suggest-thumb" aria-hidden="true">
                  {thumbnail
                    ? <img src={thumbnail} alt="" loading="lazy" />
                    : <span>{(name || '?').slice(0, 1)}</span>}
                </span>
                <span className="qc-suggest-copy">
                  <span className="qc-suggest-name">{name}</span>
                  {description && <span className="qc-suggest-bio">{description}</span>}
                </span>
                <span className="mono qc-suggest-hint">↵</span>
              </button>
            );
          })}
          {loadingMore && (
            <div className="qc-suggest-loading mono">loading more names…</div>
          )}
        </div>
      )}
    </div>
  );
}

// ─── Names browser overlay ─────────────────────────────────────
// Full-screen dark panel over the slideshow. Users search/filter the
// full people index and assign Person 01 / Person 02 for tracing.
function NamesBrowser({ personA, setPersonA, personB, setPersonB, onClose, onTrace }) {
  const [query, setQuery]               = React.useState('');
  const [country, setCountry]           = React.useState('');
  const [letter, setLetter]             = React.useState('');
  const [people, setPeople]             = React.useState([]);
  const [total, setTotal]               = React.useState(0);
  const [hasMore, setHasMore]           = React.useState(false);
  const [loading, setLoading]           = React.useState(false);
  const [loadingMore, setLoadingMore]   = React.useState(false);
  const [countries, setCountries]       = React.useState([]);
  const [offset, setOffset]             = React.useState(0);
  const debounceRef                     = React.useRef(null);
  const listRef                         = React.useRef(null);
  const LIMIT = 50;
  const ALPHA = ['#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];

  // Load country list once
  React.useEffect(() => {
    fetch('/api/people/countries')
      .then(r => r.ok ? r.json() : { countries: [] })
      .then(d => setCountries(d.countries || []))
      .catch(() => {});
  }, []);

  // Fetch people from API
  const fetchPeople = React.useCallback(async ({ q, ctry, ltr, off, append }) => {
    if (append) setLoadingMore(true); else setLoading(true);
    try {
      const params = new URLSearchParams({ limit: LIMIT, offset: off || 0 });
      if (q)    params.set('q', q);
      if (ctry) params.set('countryOfBirth', ctry);
      if (ltr)  params.set('startsWith', ltr);
      const resp = await fetch('/api/people?' + params);
      if (!resp.ok) return;
      const data = await resp.json();
      const list = data.people || [];
      setPeople(prev => append ? [...prev, ...list] : list);
      setTotal(data.total || 0);
      setHasMore(data.hasMore || false);
      setOffset((off || 0) + list.length);
    } catch (_) {}
    finally { setLoading(false); setLoadingMore(false); }
  }, []);

  // Initial load and on filter change (debounced)
  React.useEffect(() => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      setOffset(0);
      fetchPeople({ q: query, ctry: country, ltr: letter, off: 0, append: false });
    }, 250);
    return () => clearTimeout(debounceRef.current);
  }, [query, country, letter, fetchPeople]);

  const loadMore = () => {
    if (loadingMore || !hasMore) return;
    fetchPeople({ q: query, ctry: country, ltr: letter, off: offset, append: true });
  };

  // Infinite scroll
  React.useEffect(() => {
    const el = listRef.current;
    if (!el) return;
    const onScroll = () => {
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120) loadMore();
    };
    el.addEventListener('scroll', onScroll);
    return () => el.removeEventListener('scroll', onScroll);
  }, [loadMore]);

  const clearFilters = () => { setQuery(''); setCountry(''); setLetter(''); };
  const canTrace = personA.trim() && personB.trim();

  const handleTraceFromBrowser = () => {
    if (!canTrace) return;
    onTrace();
  };

  return (
    <div className="nb-backdrop" role="dialog" aria-label="Browse people index"
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="nb-panel">

        {/* Header */}
        <div className="nb-header">
          <div className="nb-header-left">
            <div className="nb-title mono">INDEX OF PEOPLE</div>
            <div className="nb-subtitle">
              Search the full index, filter by country of birth, then assign two people to trace a path.
            </div>
          </div>
          <div className="nb-header-right">
            <span className="nb-count mono">{total.toLocaleString()} people</span>
            <button className="qc-close mono" onClick={onClose} aria-label="Close">
              <span>ESC</span>
              <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
                <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
              </svg>
            </button>
          </div>
        </div>

        {/* Controls */}
        <div className="nb-controls">
          <div className="nb-search-wrap">
            <input
              className="nb-search"
              placeholder="search by name…"
              value={query}
              onChange={e => { setQuery(e.target.value); setLetter(''); }}
              autoFocus
              autoComplete="off"
              spellCheck="false"
            />
          </div>
          <div className="nb-select-wrap">
            <select
              className="nb-select"
              value={country}
              onChange={e => setCountry(e.target.value)}
            >
              <option value="">all countries</option>
              {countries.map(c => (
                <option key={c.country} value={c.country}>
                  {c.country} ({c.count.toLocaleString()})
                </option>
              ))}
            </select>
          </div>
          {(query || country || letter) && (
            <button className="nb-clear mono" onClick={clearFilters}>clear ×</button>
          )}
        </div>

        {/* Alphabet jump bar */}
        <div className="nb-alpha">
          {ALPHA.map(l => (
            <button
              key={l}
              className={'nb-alpha-btn mono' + (letter === l ? ' nb-alpha-btn--active' : '')}
              onClick={() => { setLetter(letter === l ? '' : l); setQuery(''); }}
            >{l}</button>
          ))}
        </div>

        {/* List */}
        <div className="nb-list" ref={listRef}>
          {loading && (
            <div className="nb-status mono">searching the index…</div>
          )}
          {!loading && people.length === 0 && (
            <div className="nb-status mono">
              no names found. try a broader search or remove the country filter.
            </div>
          )}
          {!loading && people.map(p => {
            const isA = personA === p.name;
            const isB = personB === p.name;
            return (
              <div key={p.id || p.name}
                className={'nb-row' + (isA ? ' nb-row--a' : isB ? ' nb-row--b' : '')}>
                {p.thumbnail && (
                  <img
                    className="nb-row-thumb"
                    src={p.thumbnail}
                    alt=""
                    loading="lazy"
                  />
                )}
                <div className="nb-row-info">
                  <div className="nb-row-name">{p.name}</div>
                  {p.country && (
                    <div className="nb-row-meta mono">{p.country}</div>
                  )}
                  {p.description && (
                    <div className="nb-row-desc">{p.description}</div>
                  )}
                </div>
                <div className="nb-row-actions">
                  <button
                    className={'nb-assign' + (isA ? ' nb-assign--active' : '')}
                    onClick={() => setPersonA(isA ? '' : p.name)}
                    title="Set as Person 01"
                  >P01</button>
                  <button
                    className={'nb-assign' + (isB ? ' nb-assign--active' : '')}
                    onClick={() => setPersonB(isB ? '' : p.name)}
                    title="Set as Person 02"
                  >P02</button>
                </div>
              </div>
            );
          })}
          {loadingMore && <div className="nb-status mono">loading…</div>}
          {!loadingMore && hasMore && (
            <button className="nb-load-more mono" onClick={loadMore}>load more</button>
          )}
        </div>

        {/* Selection rail */}
        <div className={'nb-rail' + (canTrace ? ' nb-rail--ready' : '')}>
          <div className="nb-rail-slot">
            <span className="nb-rail-label mono">PERSON 01</span>
            <span className={'nb-rail-name' + (personA ? ' nb-rail-name--set' : '')}>
              {personA || <span className="nb-rail-empty">not selected</span>}
            </span>
          </div>
          <div className="nb-rail-sep mono">↔</div>
          <div className="nb-rail-slot">
            <span className="nb-rail-label mono">PERSON 02</span>
            <span className={'nb-rail-name' + (personB ? ' nb-rail-name--set' : '')}>
              {personB || <span className="nb-rail-empty">not selected</span>}
            </span>
          </div>
          <button
            className={'nb-trace' + (canTrace ? ' nb-trace--ready' : '')}
            disabled={!canTrace}
            onClick={handleTraceFromBrowser}
          >
            trace connection
            <svg width="14" height="10" viewBox="0 0 18 12" fill="none" style={{ marginLeft: 6 }}>
              <path d="M1 6 H17 M12 1 L17 6 L12 11" stroke="currentColor" strokeWidth="1.2" />
            </svg>
          </button>
        </div>

      </div>
    </div>
  );
}

// ─── usePhotoNetwork hook ─────────────────────────────────────
function usePhotoNetwork(photoId, opts = {}) {
  const { limit = 18, perPersonLimit = 5, shuffleKey = 0 } = opts;
  const [data, setData]       = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const [error, setError]     = React.useState(null);

  React.useEffect(() => {
    if (!photoId) return;
    setLoading(true); setError(null);
    // NOTE: do NOT reset data here — keeps old graph visible while loading
    const params = new URLSearchParams({ limit, perPersonLimit });
    if (shuffleKey) params.set('shuffleKey', shuffleKey);
    fetch(`/api/photos/${encodeURIComponent(photoId)}/network?${params}`, {
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          'X-Application-Request': 'true',
          'Accept': 'application/json',
        },
      })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => { setData(d); setLoading(false); })
      .catch(() => { setError(true); setLoading(false); });
  }, [photoId, limit, shuffleKey]);

  return { data, loading, error };
}

// ─── Person-color palette (edge + via-label grouping) ─────────
const NG_PERSON_PALETTE = [
  'rgba(200,160,90,1)',   // gold (matches accent)
  'rgba(100,190,255,1)',  // sky
  'rgba(190,130,230,1)',  // lavender
  'rgba(100,220,185,1)',  // teal
  'rgba(255,145,90,1)',   // amber
  'rgba(220,100,160,1)',  // rose
];

function buildPersonColorMap(nodes) {
  const seen = [];
  nodes.forEach(n => (n.sharedPeople || []).forEach(p => {
    if (!seen.includes(p.name)) seen.push(p.name);
  }));
  const map = {};
  seen.forEach((name, i) => { map[name] = NG_PERSON_PALETTE[i % NG_PERSON_PALETTE.length]; });
  return map;
}

// ─── NetworkInspectorPanel ────────────────────────────────────
function NetworkInspectorPanel({ node, onMakeCentral, onClose }) {
  const [personModal, setPersonModal] = React.useState(null);
  const people       = node.people       || [];
  const sharedPeople = node.sharedPeople || [];
  const hasDate = node.date     && node.date     !== 'Unknown';
  const locationLabel = formatPhotoLocation(node.location, '');
  const hasLoc  = !!locationLabel;

  return (
    <div className="ng-inspector">
      {/* Connected-via — most important signal, pinned to top */}
      {sharedPeople.length > 0 && (
        <div className="ng-insp-via-banner">
          <span className="ng-insp-via-kicker mono">connected via</span>
          <span className="ng-insp-via-names">
            {sharedPeople.map(p => p.name).join(' · ')}
          </span>
        </div>
      )}

      <div className="ng-insp-img-wrap">
        <img className="ng-insp-img" src={node.imageUrl} alt={node.caption || ''} loading="lazy" />
      </div>

      <div className="ng-insp-body">
        {node.caption && (
          <div className="ng-insp-caption">{node.caption}</div>
        )}
        {(hasDate || hasLoc) && (
          <div className="pv-details mono">
            {hasDate ? node.date : ''}
            {hasDate && hasLoc ? ' · ' : ''}
            {hasLoc ? locationLabel : ''}
          </div>
        )}
        {people.length > 0 && (
          <div>
            <div className="pv-people-label mono">PEOPLE IN PHOTO</div>
            <div className="ng-insp-people-tags">
              {people.map((p, i) => {
                const isShared = sharedPeople.some(s => s.name === p.name);
                return (
                  <span key={i}
                    className={'ng-person-tag' + (isShared ? ' ng-person-tag--shared' : '')}
                    onClick={() => { if (p.name) setPersonModal(p.name); }}
                  >{p.name}</span>
                );
              })}
            </div>
          </div>
        )}
      </div>

      <div className="ng-insp-actions">
        <button className="ng-insp-primary" onClick={() => onMakeCentral(node)}>
          Enter this photograph →
        </button>
        <button className="ng-insp-secondary" onClick={onClose}>
          Return to Odyssey
        </button>
      </div>

      {personModal && (
        <PersonModal
          name={personModal}
          onClose={() => setPersonModal(null)}
          onSelectPerson={setPersonModal}
        />
      )}
    </div>
  );
}

// ─── NetworkGraphView ─────────────────────────────────────────
function NetworkGraphView({ seedPhoto, onClose }) {
  const [internalSeed, setInternalSeed] = React.useState(seedPhoto);
  const seedId = internalSeed.PhotoID || internalSeed.id;

  // Density / shuffle controls
  const [densityMode, setDensityMode] = React.useState('near');
  const [shuffleKey,  setShuffleKey]  = React.useState(0);
  const DENSITY = {
    near:  { limit: 40,  perPersonLimit: 10 },
    wider: { limit: 80,  perPersonLimit: 20 },
    dense: { limit: 150, perPersonLimit: 35 },
  };
  const { limit, perPersonLimit } = DENSITY[densityMode];

  const { data, loading, error } = usePhotoNetwork(seedId, { limit, perPersonLimit, shuffleKey });

  // Travel breadcrumb
  const [visitedPhotos, setVisitedPhotos] = React.useState([{
    id:       seedId,
    imageUrl: internalSeed.PhotoMeta || internalSeed.imageUrl || '',
    caption:  internalSeed.PhotoDescription || internalSeed.caption || '',
  }]);

  const [positions,      setPositions]      = React.useState({});
  const [selectedId,     setSelectedId]     = React.useState(null);
  const [hoveredId,      setHoveredId]      = React.useState(null);
  const [highlightedIdx, setHighlightedIdx] = React.useState(-1);
  const [enteringId,     setEnteringId]     = React.useState(null);
  const [dragState,      setDragState]      = React.useState(null);
  const containerRef = React.useRef(null);
  const stageRef     = React.useRef(null);   // for cursor mutation
  const canvasRef    = React.useRef(null);   // panning div
  const panRef       = React.useRef({ x: 0, y: 0 });
  const panStateRef  = React.useRef(null);   // { startX, startY, startPanX, startPanY }
  const [dims, setDims] = React.useState({ w: 0, h: 0 });

  // Node sizes — viewport-responsive
  const SEED_W = Math.min(Math.max(448, dims.w * 0.416), 672);
  const SEED_H = Math.round(SEED_W * 0.72);
  const BASE_W = Math.min(Math.max(176, dims.w * 0.144), 248);
  const BASE_H = Math.round(BASE_W * 0.75);

  // Measure container
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const obs = new ResizeObserver(entries => {
      const e = entries[0].contentRect;
      setDims({ w: e.width, h: e.height });
    });
    obs.observe(el);
    setDims({ w: el.clientWidth, h: el.clientHeight });
    return () => obs.disconnect();
  }, []);

  // Sort & group by shared person — creates natural arcs
  const sorted = React.useMemo(() => {
    const nodes = data?.nodes || [];
    return [...nodes].sort((a, b) =>
      (a.sharedPeople[0]?.name || '').localeCompare(b.sharedPeople[0]?.name || ''));
  }, [data]);

  // Per-person color assignment
  const personColors = React.useMemo(() => buildPersonColorMap(sorted), [sorted]);

  // Adaptive multi-ring radial layout — fills as many rings as needed
  const computePositions = React.useCallback(() => {
    if (!data || dims.w === 0 || dims.h === 0 || sorted.length === 0) return;
    const inspOpen = !!selectedId;
    const stageW   = inspOpen ? dims.w - 370 : dims.w;
    const cx       = inspOpen ? stageW / 2   : dims.w / 2;
    const cy       = dims.h / 2;

    const minR     = SEED_W / 2 + BASE_W / 2 + 28;
    const ringStep = (BASE_W + BASE_H) / 2 * 1.35; // spacing between ring centres
    let r          = Math.max(minR, Math.min(stageW * 0.26, dims.h * 0.30));
    let i          = 0;

    const newPos = { [seedId]: { x: cx, y: cy } };

    while (i < sorted.length) {
      const circumference = 2 * Math.PI * r;
      const capacity      = Math.max(3, Math.floor(circumference / (BASE_W * 1.10)));
      const ring          = sorted.slice(i, i + capacity);
      // Slight rotation per ring so arcs of outer rings don't sit exactly behind inner ones
      const angleOffset   = -Math.PI / 2 + (i === 0 ? 0 : (r / minR) * 0.18);
      ring.forEach((node, j) => {
        const jitter = ((j * 7 + i * 3) % 11 - 5) * 0.03 * r;
        const angle  = angleOffset + (j / ring.length) * 2 * Math.PI;
        newPos[node.id] = {
          x: cx + (r + jitter) * Math.cos(angle),
          y: cy + (r + jitter * 0.5) * Math.sin(angle),
        };
      });
      i += ring.length;
      r += ringStep;
    }
    setPositions(newPos);
  }, [data, dims, selectedId, sorted, seedId, SEED_W, BASE_W, BASE_H]);

  React.useLayoutEffect(() => { computePositions(); }, [computePositions]);

  // Smooth pan — direct DOM mutation, zero React re-renders per frame
  const applyPan = React.useCallback((x, y) => {
    panRef.current = { x, y };
    if (canvasRef.current) canvasRef.current.style.transform = `translate(${x}px,${y}px)`;
  }, []);
  const resetPan = React.useCallback(() => applyPan(0, 0), [applyPan]);

  // URL state — update query param while in network mode
  React.useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    params.set('network', seedId);
    history.replaceState(null, '', '?' + params.toString());
    return () => {
      const p2 = new URLSearchParams(window.location.search);
      p2.delete('network');
      const qs = p2.toString();
      history.replaceState(null, '', qs ? '?' + qs : window.location.pathname);
    };
  }, [seedId]);

  // Keyboard navigation
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {
        if (selectedId) { setSelectedId(null); setHighlightedIdx(-1); return; }
        onClose(); return;
      }
      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
        e.preventDefault();
        setHighlightedIdx(i => (i + 1) % Math.max(sorted.length, 1));
        return;
      }
      if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
        e.preventDefault();
        setHighlightedIdx(i => i <= 0 ? sorted.length - 1 : i - 1);
        return;
      }
      if (e.key === 'Enter' && highlightedIdx >= 0 && sorted[highlightedIdx]) {
        handleMakeCentral(sorted[highlightedIdx]); return;
      }
      if (e.key === ' ') {
        e.preventDefault();
        const n = highlightedIdx >= 0 ? sorted[highlightedIdx] : null;
        if (n) setSelectedId(id => id === n.id ? null : n.id);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedId, onClose, highlightedIdx, sorted]);

  // Stage background → start canvas pan
  const handleStageMouseDown = React.useCallback((e) => {
    if (e.target.closest('.ng-node') || e.target.closest('.ng-inspector')) return;
    e.preventDefault();
    panStateRef.current = {
      startX: e.clientX, startY: e.clientY,
      startPanX: panRef.current.x, startPanY: panRef.current.y,
    };
    if (stageRef.current) stageRef.current.style.cursor = 'grabbing';
  }, []);

  // Node drag
  const handleDragStart = (e, nodeId) => {
    if (nodeId === seedId) return;
    e.preventDefault();
    e.stopPropagation(); // don't start canvas pan
    const pos = positions[nodeId];
    if (!pos) return;
    setDragState({ id: nodeId, ox: e.clientX - pos.x, oy: e.clientY - pos.y });
  };

  // Unified mouse move — drag takes priority over pan
  const handleMouseMove = (e) => {
    if (dragState) {
      setPositions(p => ({ ...p, [dragState.id]: { x: e.clientX - dragState.ox, y: e.clientY - dragState.oy } }));
      return;
    }
    if (panStateRef.current) {
      applyPan(
        panStateRef.current.startPanX + e.clientX - panStateRef.current.startX,
        panStateRef.current.startPanY + e.clientY - panStateRef.current.startY,
      );
    }
  };

  const handleMouseUp = () => {
    setDragState(null);
    panStateRef.current = null;
    if (stageRef.current) stageRef.current.style.cursor = 'grab';
  };

  // Navigate into a connected photo — cinematic transition
  const handleMakeCentral = (node) => {
    setEnteringId(node.id);
    setSelectedId(null);
    setHighlightedIdx(-1);
    setHoveredId(null);
    resetPan();
    setTimeout(() => {
      setInternalSeed({
        PhotoID:          node.id,
        PhotoMeta:        node.imageUrl,
        PhotoDescription: node.caption,
        PhotoDate:        node.date,
        PhotoLocation:    node.location,
        People:           node.people,
      });
      setVisitedPhotos(prev => {
        const filtered = prev.filter(p => p.id !== node.id);
        return [
          { id: node.id, imageUrl: node.imageUrl, caption: node.caption },
          ...filtered,
        ].slice(0, 8);
      });
      setEnteringId(null);
    }, 380);
  };

  // Return to a breadcrumb photo
  const handleBreadcrumbNav = (p) => {
    if (p.id === seedId) return;
    setSelectedId(null);
    setHighlightedIdx(-1);
    resetPan();
    setInternalSeed({ PhotoID: p.id, PhotoMeta: p.imageUrl, PhotoDescription: p.caption });
  };

  // Node size variant — more shared people → slightly larger
  const nodeSize = (node) => {
    const extra = Math.min((node.sharedPeople?.length || 0) - 1, 3);
    return { w: BASE_W + extra * 10, h: BASE_H + extra * 7 };
  };

  const seedPos      = positions[seedId] || { x: dims.w / 2, y: dims.h / 2 };
  const selectedNode = selectedId ? sorted.find(n => n.id === selectedId) : null;
  const isEmpty   = !loading && !error && data && (data.nodes || []).length === 0;
  const showGraph = !loading && !error && data && (data.nodes || []).length > 0;

  // Edge color from first shared person
  const edgePersonColor = (edge) => {
    const node   = sorted.find(n => n.id === edge.target);
    const person = node?.sharedPeople?.[0]?.name;
    return person ? personColors[person] : 'rgba(200,160,90,0.9)';
  };

  return (
    <div
      className="ng-backdrop"
      ref={containerRef}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      {/* ── Header ── */}
      <div className="ng-header">
        <span className="ng-status">● NETWORK MODE</span>
        <span className="ng-subline">
          {(internalSeed.PhotoDate || internalSeed.date)
            ? (internalSeed.PhotoDate || internalSeed.date)
            : ''}
          {(internalSeed.PhotoDate || internalSeed.date) && (internalSeed.PhotoLocation || internalSeed.location)
            ? ' · ' : ''}
          {formatPhotoLocation(internalSeed.PhotoLocation || internalSeed.location, '')}
        </span>

        {/* Density + shuffle controls */}
        <div className="ng-hdr-center">
          {['near', 'wider', 'dense'].map(mode => (
            <button key={mode}
              className={'ng-density-btn mono' + (densityMode === mode ? ' ng-density-btn--active' : '')}
              onClick={() => setDensityMode(mode)}
            >{mode}</button>
          ))}
          <button
            className="ng-density-btn mono"
            onClick={() => setShuffleKey(k => k + 1)}
            title="Draw a different constellation"
          >shuffle ↻</button>
        </div>

        <div className="ng-hdr-right">
          <button className="ng-btn mono" onClick={() => { resetPan(); computePositions(); }}>RECENTER</button>
          <button className="ng-btn mono" onClick={onClose} aria-label="Close">
            <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
              <path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" strokeWidth="1.2" />
            </svg>
          </button>
        </div>
      </div>

      {/* ── Stage ── */}
      <div className="ng-stage" ref={stageRef} style={{ cursor: 'grab' }} onMouseDown={handleStageMouseDown}>

        {/* Full loading state (first fetch) */}
        {loading && !data && (
          <div className="ng-state-overlay mono">MAPPING NEARBY PHOTOGRAPHS…</div>
        )}
        {/* Subtle pulse while refreshing existing graph */}
        {loading && data && (
          <div className="ng-loading-pulse mono">MAPPING…</div>
        )}
        {error && (
          <div className="ng-state-overlay mono">COULD NOT MAP THIS PART OF THE ARCHIVE.</div>
        )}
        {isEmpty && (
          <div className="ng-state-overlay mono">NO NEARBY PHOTOGRAPHS FOUND FOR THIS IMAGE.</div>
        )}

        {showGraph && (
          <>
            {/* Panning canvas — SVG + nodes translate together; inspector stays fixed */}
            <div ref={canvasRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
            {/* SVG edges */}
            <svg className="ng-svg-edges">
              {(data.edges || []).map(edge => {
                const sp = positions[edge.source], tp = positions[edge.target];
                if (!sp || !tp) return null;
                const isActive = hoveredId === edge.target
                  || selectedId === edge.target
                  || (highlightedIdx >= 0 && sorted[highlightedIdx]?.id === edge.target);
                const color = edgePersonColor(edge);
                const label = isActive && edge.sharedPeople?.length > 0
                  ? edge.sharedPeople.map(p => p.name).join(' + ')
                  : null;
                const mx = (sp.x + tp.x) / 2, my = (sp.y + tp.y) / 2;
                return (
                  <g key={edge.target}>
                    <line
                      x1={sp.x} y1={sp.y} x2={tp.x} y2={tp.y}
                      stroke={isActive ? color : 'rgba(200,160,90,0.15)'}
                      strokeWidth={isActive ? 2 : 0.7}
                      strokeDasharray={isActive ? 'none' : '3 6'}
                    />
                    {label && (
                      <text
                        x={mx} y={my - 6}
                        fontFamily="var(--mono)" fontSize="8"
                        fill={color} textAnchor="middle"
                        style={{ letterSpacing: '0.08em', pointerEvents: 'none' }}
                      >via {label}</text>
                    )}
                  </g>
                );
              })}
            </svg>

            {/* Nodes layer */}
            <div className="ng-nodes-layer">

              {/* ── Central / seed node ── */}
              <div
                className="ng-node ng-node--seed"
                style={{
                  left:   seedPos.x - SEED_W / 2,
                  top:    seedPos.y - SEED_H / 2,
                  width:  SEED_W,
                  height: SEED_H,
                }}
              >
                <img
                  src={internalSeed.PhotoMeta || internalSeed.imageUrl}
                  className="ng-node-img"
                  alt=""
                />
                {internalSeed.PhotoDescription && (
                  <div className="ng-seed-label mono">{internalSeed.PhotoDescription}</div>
                )}
              </div>

              {/* ── Connected nodes ── */}
              {sorted.map((node, idx) => {
                const pos     = positions[node.id] || { x: dims.w / 2, y: dims.h / 2 };
                const isHov   = hoveredId      === node.id;
                const isSel   = selectedId     === node.id;
                const isKbd   = highlightedIdx === idx;
                const isEnter = enteringId     === node.id;
                const { w, h } = nodeSize(node);
                const personName = node.sharedPeople?.[0]?.name;
                const pColor = personName ? personColors[personName] : null;

                let cls = 'ng-node';
                if (isSel)              cls += ' ng-node--selected';
                else if (isHov || isKbd) cls += ' ng-node--hovered';
                if (isEnter)            cls += ' ng-node--entering';

                return (
                  <div key={node.id}
                    className={cls}
                    style={{
                      left:          pos.x - w / 2,
                      top:           pos.y - h / 2,
                      width:         w,
                      height:        h,
                      pointerEvents: 'auto',
                      cursor:        'pointer',
                      ...(pColor && (isHov || isSel || isKbd)
                        ? { borderColor: pColor }
                        : {}),
                    }}
                    onMouseEnter={() => { setHoveredId(node.id); setHighlightedIdx(idx); }}
                    onMouseLeave={() => { setHoveredId(null); }}
                    onClick={() => setSelectedId(isSel ? null : node.id)}
                    onDoubleClick={() => handleMakeCentral(node)}
                    onMouseDown={(e) => handleDragStart(e, node.id)}
                  >
                    <img src={node.imageUrl} className="ng-node-img" loading="lazy" alt="" />

                    {/* Person via-label pinned to bottom of node */}
                    {personName && (
                      <div
                        className="ng-node-via mono"
                        style={{ color: pColor || 'var(--not-accent)' }}
                      >{personName}</div>
                    )}

                    {/* Hover/select overlay: "enter →" affordance */}
                    {(isHov || isSel || isKbd) && (
                      <div className="ng-node-enter-overlay">
                        <button
                          className="ng-node-enter-btn"
                          onClick={(e) => { e.stopPropagation(); handleMakeCentral(node); }}
                        >enter →</button>
                        {node.sharedPeople?.length > 0 && (
                          <div className="ng-node-via-tip mono">
                            via {node.sharedPeople.map(p => p.name).join(' · ')}
                          </div>
                        )}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
            </div>{/* end canvasRef */}

            {/* Inspector panel — outside canvas, stays fixed to stage right edge */}
            {selectedNode && (
              <NetworkInspectorPanel
                node={selectedNode}
                onMakeCentral={handleMakeCentral}
                onClose={onClose}
              />
            )}
          </>
        )}
      </div>

      {/* ── Footer: breadcrumb rail + description ── */}
      <div className="ng-footer">
        {visitedPhotos.length > 1 && (
          <div className="ng-breadcrumb">
            <span className="ng-breadcrumb-label mono">path travelled</span>
            <div className="ng-breadcrumb-rail">
              {visitedPhotos.map(p => (
                <div
                  key={p.id}
                  className={'ng-breadcrumb-thumb' + (p.id === seedId ? ' ng-breadcrumb-thumb--active' : '')}
                  title={p.caption || ''}
                  onClick={() => handleBreadcrumbNav(p)}
                >
                  {p.imageUrl && <img src={p.imageUrl} alt="" />}
                </div>
              ))}
            </div>
          </div>
        )}
        <span className="ng-footer-desc mono">
          {sorted.length > 0
            ? `${sorted.length} photographs · drag background to pan · double-click to enter`
            : 'photographs connected through shared appearances'}
        </span>
      </div>
    </div>
  );
}

Object.assign(window, { Projection });
