/* global React, ReactDOM, ErrorBoundary, TweaksPanel, TweakSection, TweakSlider, TweakToggle, TweakSelect, useTweaks */
const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React;

/* -------------------------------------------------------------------------
 * Data: five locations (normalized coordinates on the 1681×936 base map)
 * Path order per latest brief:
 *   start → 01 档案馆 (central dome) → 02 雷达站 (radar dish, lower-right)
 *         → 03 光影馆 (small hut near mountain) → 04 灯塔 (top-right edge)
 * ------------------------------------------------------------------------- */
const MAP_W = 1681;
const MAP_H = 936;

// Coordinates expressed as percentages of the map image
const LOCATIONS = [
  {
    id: "archive",
    order: "01",
    name: "档案馆",
    nameEn: "ARCHIVE",
    type: "长文 / LONG-FORM",
    coord: "",
    x: 0.398, y: 0.685,   // central dome (was 光影馆)
    card: { side: "right", dy: 0 },
    tProgress: 0.22,
    count: { unit: "长文" },
    blurb: "这颗星球最古老的建筑。厚重、可进入、可长期保存知识。编号、标题、时间、坐标。",
    /* entries derived live from window.ARCHIVES via getEntries() — keeps the
       map card in sync with the corpus on archive-reader. */
    getEntries: () => {
      const list = (typeof window !== "undefined" && window.ARCHIVES) || [];
      return list.slice(0, 3).map(a => ({
        n: `A-${a.n}`,
        t: a.title,
        dur: a.date,
      }));
    },
  },
  {
    id: "radar",
    order: "02",
    name: "雷达站",
    nameEn: "SIGNAL STATION",
    type: "新闻 · 每周信号 / SIGNAL",
    coord: "",
    x: 0.582, y: 0.715,   // radar dish
    card: { side: "right", dy: 0 },
    tProgress: 0.45,
    count: { done: 7, total: 24, unit: "信号" },
    blurb: "高频、碎片化、带强烈个人观察的信号捕捉。雷达碟、天线阵列、信号波纹。",
    entries: [
      { n: "S-024", t: "LOG 08 / 2030  频谱偏移", dur: "3min" },
      { n: "S-023", t: "LOG 07 / 2030  西岸回响", dur: "2min" },
      { n: "S-022", t: "LOG 06 / 2030  未命名脉冲", dur: "4min" },
    ],
    locked: true,
    lockedNote: {
      status: "OFFLINE · 离线",
      heading: "主天线在第三次未命名脉冲后自动断电。",
      body: "我去折叠点以南找发电机了。回来再开机。",
      sign: "—— 42 号宇航员 · Day 211",
      badge: "OFFLINE",
      badgeCn: "离线",
    },
  },
  {
    id: "observatory",
    order: "03",
    name: "光影馆",
    nameEn: "HALL OF LIGHT",
    type: "视频 / VIDEO",
    coord: "",
    x: 0.498, y: 0.305,   // small stone tower above & slightly left of radar (was 档案馆)
    card: { side: "left", dy: 40 },
    tProgress: 0.68,
    count: { done: 1, total: 8, unit: "影片" },
    blurb: "放映厅式建筑。投影幕、胶片轨道、观看装置。左右切换视频，选中居中放大。",
    entries: [
      { n: "V-003", t: "折叠点的第一缕光", dur: "04:12" },
      { n: "V-002", t: "矿物蓝的夜", dur: "02:58" },
      { n: "V-001", t: "抵达", dur: "01:34" },
    ],
    locked: true,
    lockedNote: {
      status: "SYNCING · 校准中",
      heading: "投影幕上仍在跑校准条纹。",
      body: "胶片轨道每隔 47 秒回弹一次，影像信号尚未稳定。",
      sign: "下一次同步窗口：第 14 个昼夜周期。",
      badge: "SYNCING",
      badgeCn: "校准中",
    },
  },
  {
    id: "lighthouse",
    order: "04",
    name: "灯塔",
    nameEn: "LIGHTHOUSE",
    type: "碎片 · 短卡片 / SHARDS",
    coord: "",
    x: 0.838, y: 0.155,   // top-right lighthouse
    card: { side: "left", dy: 140, gap: 60 },  // user override: closer to building, lower
    tProgress: 0.92,
    count: { done: 5, total: 30, unit: "卡片" },
    blurb: "地图边缘，旁边即是海域。碎片与短卡片的聚合点。顶端常亮烧金橙。",
    entries: [
      { n: "F-030", t: "海岸的拼写错误", dur: "—" },
      { n: "F-029", t: "夜里的三行字", dur: "—" },
      { n: "F-028", t: "给42号的明信片", dur: "—" },
    ],
    locked: true,
    lockedNote: {
      status: "DORMANT · 未点亮",
      heading: "灯室的烧金橙灯芯仍封存在底层。",
      body: "海岸观测节点尚未启用。",
      sign: "点亮条件：档案馆建立完整索引之后。",
      badge: "DORMANT",
      badgeCn: "未点亮",
    },
  },
];

const START = { x: 0.115, y: 0.84 };   // bottom-left start (折叠点)

/* -------------------------------------------------------------------------
 * Build a smooth cubic-bezier path through the waypoints.
 * Returns SVG path d-string in viewBox coords (0..MAP_W, 0..MAP_H).
 * ------------------------------------------------------------------------- */
function buildPathD(points) {
  if (!points.length) return "";
  const pts = points.map(p => ({ x: p.x * MAP_W, y: p.y * MAP_H }));
  let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i];
    const p1 = pts[i + 1];
    const prev = pts[i - 1] || p0;
    const next = pts[i + 2] || p1;
    // Catmull-Rom to bezier
    const c1 = { x: p0.x + (p1.x - prev.x) / 5.2, y: p0.y + (p1.y - prev.y) / 5.2 };
    const c2 = { x: p1.x - (next.x - p0.x) / 5.2, y: p1.y - (next.y - p0.y) / 5.2 };
    d += ` C ${c1.x.toFixed(1)} ${c1.y.toFixed(1)}, ${c2.x.toFixed(1)} ${c2.y.toFixed(1)}, ${p1.x.toFixed(1)} ${p1.y.toFixed(1)}`;
  }
  return d;
}

const WAYPOINTS = [START, ...LOCATIONS.map(l => ({ x: l.x, y: l.y }))];
const PATH_D = buildPathD(WAYPOINTS);

const clampStopIndex = (i) => {
  const n = Number.isFinite(i) ? i : -1;
  return Math.max(-1, Math.min(LOCATIONS.length - 1, n));
};
const stopIndexToT = (i) => (i < 0 ? 0 : LOCATIONS[i].tProgress);

function readInitialStopIndex() {
  if (typeof window === "undefined") return -1;
  try {
    const savedStop = sessionStorage.getItem("inkland.mapStopIndex");
    if (savedStop !== null) {
      sessionStorage.removeItem("inkland.mapStopIndex");
      return clampStopIndex(parseInt(savedStop, 10));
    }
    // Clear the old scroll-restoration key from the former free-scroll version.
    sessionStorage.removeItem("inkland.mapScrollY");
  } catch(e) {}
  return -1;
}

/* ---------------------------------------------------------------------------
 * Per-segment pass counts.
 *
 * The path is discretized into N_SEGMENTS pieces. Each piece keeps a count of
 * how many times the rover has traversed it. Counts are monotonic (only ever
 * incremented) and capped at MAX_PASSES.
 *
 * Why per-segment: a single highwater mark can't express "this stretch has
 * been crossed three times while that one only once". Going forward → back
 * → forward over the same stretch should leave it darker than just having
 * driven through once.
 *
 * Counts persist across building visits via sessionStorage.
 * ------------------------------------------------------------------------- */
const N_SEGMENTS = 240;
const MAX_PASSES = 3;

function readInitialPassCounts() {
  if (typeof window === "undefined") return null;
  try {
    const saved = sessionStorage.getItem("inkland.mapPassCounts");
    if (saved !== null) {
      sessionStorage.removeItem("inkland.mapPassCounts");
      if (typeof saved === "string" && saved.length === N_SEGMENTS) {
        const out = new Uint8Array(N_SEGMENTS);
        for (let i = 0; i < N_SEGMENTS; i++) {
          const c = saved.charCodeAt(i) - 48; // '0' = 48
          out[i] = (c >= 0 && c <= 9) ? c : 0;
        }
        return out;
      }
    }
    // Clear any leftover key from earlier highwater-only model.
    sessionStorage.removeItem("inkland.mapMaxJourneyT");
  } catch(e) {}
  return null;
}

/* On a fresh load with restored journey position, fill in the segments
   the rover would have already crossed to get there (count = 1).
   When journeyT === 0, this returns all zeros — no tracks until the user
   actually drives. */
function makeInitialPassCounts(initialT) {
  const out = new Uint8Array(N_SEGMENTS);
  if (initialT <= 1e-6) return out;
  const lastIdx = Math.min(N_SEGMENTS - 1, Math.floor(initialT * N_SEGMENTS));
  for (let i = 0; i <= lastIdx; i++) out[i] = 1;
  return out;
}

function passCountsToString(counts) {
  let s = "";
  for (let i = 0; i < counts.length; i++) {
    s += String(Math.min(9, counts[i]));
  }
  return s;
}

/* Increment the pass count for every segment the rover crossed when moving
   from `lastT` to `newT`. Mutates `counts` in place. The "from" segment is
   not incremented — it was already counted when the rover entered it. */
function applyPassMovement(lastT, newT, counts) {
  const N = counts.length;
  if (Math.abs(newT - lastT) < 1e-9) return;
  const lastSeg = Math.max(0, Math.min(N - 1, Math.floor(lastT * N)));
  const newSeg  = Math.max(0, Math.min(N - 1, Math.floor(newT  * N)));
  if (lastSeg === newSeg) return;
  if (newSeg > lastSeg) {
    for (let i = lastSeg + 1; i <= newSeg; i++) {
      if (counts[i] < MAX_PASSES) counts[i]++;
    }
  } else {
    for (let i = newSeg; i <= lastSeg - 1; i++) {
      if (counts[i] < MAX_PASSES) counts[i]++;
    }
  }
}

/* Build a strokeDasharray pattern that reveals exactly the segments whose
   pass count is >= threshold. Used by the SVG mask to clip the tread paths.

   Example: counts = [1, 1, 0, 0, 1, 1, 1], threshold = 1
            → "2*segLen 2*segLen 3*segLen <huge gap>"
            (dash for first run of 1s, gap for run of 0s, dash for run of 1s,
             huge trailing gap so the pattern doesn't repeat). */
function buildPassThresholdDasharray(counts, threshold, segLen, totalLen) {
  const N = counts.length;
  const out = [];
  // strokeDasharray starts with a dash. If the first segment is below
  // threshold (i.e. should be a gap), prepend a 0-length dash.
  let isDash = counts[0] >= threshold;
  if (!isDash) out.push(0);
  let i = 0;
  while (i < N) {
    let runLen = 0;
    if (isDash) {
      while (i < N && counts[i] >= threshold) { runLen += segLen; i++; }
    } else {
      while (i < N && counts[i] <  threshold) { runLen += segLen; i++; }
    }
    out.push(runLen);
    isDash = !isDash;
  }
  // Pad trailing gap so the dash pattern can't repeat within the path.
  const TRAILING = totalLen * 10 + 1000;
  if (out.length % 2 === 1) {
    out.push(TRAILING);
  } else {
    out[out.length - 1] += TRAILING;
  }
  return out.join(" ");
}

/* -------------------------------------------------------------------------
 * Stage: sticky full-viewport canvas
 * ------------------------------------------------------------------------- */
function Stage() {
  const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
    "showTweaks": false,
    "trackOpacity": 0.68,
    "paperBreath": true,
    "showHUD": true,
    "pathVisible": 0.22
  }/*EDITMODE-END*/);

  const svgRef = useRef(null);
  const pathRef = useRef(null);
  const trackRef = useRef(null);
  const roverGroupRef = useRef(null);
  const stageRef = useRef(null);
  const animationRef = useRef(0);
  const movingRef = useRef(false);
  const journeyRef = useRef(0);
  const passCountsRef = useRef(null);
  const targetIndexRef = useRef(-1);
  const driveToIndexRef = useRef(null);
  const initialStopIndexRef = useRef(null);

  if (initialStopIndexRef.current === null) {
    initialStopIndexRef.current = readInitialStopIndex();
    journeyRef.current = stopIndexToT(initialStopIndexRef.current);
    targetIndexRef.current = initialStopIndexRef.current;
    // Restore per-segment pass counts if persisted; otherwise reconstruct
    // from the restored journeyT (everything up to here was crossed once).
    passCountsRef.current =
      readInitialPassCounts() || makeInitialPassCounts(journeyRef.current);
  }

  const [targetIndex, setTargetIndex] = useState(initialStopIndexRef.current);
  const [rawT, setRawT] = useState(journeyRef.current);
  const [journeyT, setJourneyT] = useState(journeyRef.current);
  const [moving, setMoving] = useState(false);
  const [pathLen, setPathLen] = useState(0);
  const [viewport, setViewport] = useState({ w: window.innerWidth, h: window.innerHeight });
  const [dismissedLocId, setDismissedLocId] = useState(null);
  const isMobile = viewport.w < 768;

  // Measure path length once mounted
  useLayoutEffect(() => {
    if (pathRef.current) setPathLen(pathRef.current.getTotalLength());
  }, []);

  // Resize
  useEffect(() => {
    const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  // Triggered navigation: one wheel/touch gesture moves the rover to one stop.
  useEffect(() => {
    const SPACER_ID = "__scroll-spacer__";
    let spacer = document.getElementById(SPACER_ID);
    if (!spacer) {
      spacer = document.createElement("div");
      spacer.id = SPACER_ID;
      document.body.appendChild(spacer);
    }
    spacer.style.cssText = "width:1px;height:100vh;pointer-events:none;opacity:0;position:relative;";

    const previousHtmlOverflow = document.documentElement.style.overflow;
    const previousBodyOverflow = document.body.style.overflow;
    const previousOverscroll = document.documentElement.style.overscrollBehavior;
    document.documentElement.style.overflow = "hidden";
    document.body.style.overflow = "hidden";
    document.documentElement.style.overscrollBehavior = "none";

    let lastTriggerAt = 0;
    let touchStartY = null;

    const triggerStep = (direction) => {
      const now = performance.now();
      if (movingRef.current || now - lastTriggerAt < 520) return;
      lastTriggerAt = now;
      const nextIndex = clampStopIndex(targetIndexRef.current + direction);
      if (nextIndex === targetIndexRef.current) return;
      driveToIndexRef.current?.(nextIndex);
    };

    const onWheel = (e) => {
      e.preventDefault();
      if (Math.abs(e.deltaY) < 8) return;
      triggerStep(e.deltaY > 0 ? 1 : -1);
    };
    const onKeyDown = (e) => {
      if (["ArrowDown", "PageDown", " "].includes(e.key)) {
        e.preventDefault();
        triggerStep(1);
      } else if (["ArrowUp", "PageUp"].includes(e.key)) {
        e.preventDefault();
        triggerStep(-1);
      }
    };
    const onTouchStart = (e) => {
      touchStartY = e.touches?.[0]?.clientY ?? null;
    };
    const onTouchMove = (e) => {
      e.preventDefault();
    };
    const onTouchEnd = (e) => {
      if (touchStartY === null) return;
      const endY = e.changedTouches?.[0]?.clientY ?? touchStartY;
      const dy = touchStartY - endY;
      touchStartY = null;
      if (Math.abs(dy) < 42) return;
      triggerStep(dy > 0 ? 1 : -1);
    };

    window.addEventListener("wheel", onWheel, { passive: false });
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("touchstart", onTouchStart, { passive: true });
    window.addEventListener("touchmove", onTouchMove, { passive: false });
    window.addEventListener("touchend", onTouchEnd, { passive: true });

    window.scrollTo(0, 0);
    return () => {
      window.removeEventListener("wheel", onWheel);
      window.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("touchstart", onTouchStart);
      window.removeEventListener("touchmove", onTouchMove);
      window.removeEventListener("touchend", onTouchEnd);
      document.documentElement.style.overflow = previousHtmlOverflow;
      document.body.style.overflow = previousBodyOverflow;
      document.documentElement.style.overscrollBehavior = previousOverscroll;
    };
  }, []);

  useEffect(() => {
    targetIndexRef.current = targetIndex;
  }, [targetIndex]);

  useEffect(() => {
    movingRef.current = moving;
  }, [moving]);

  useEffect(() => {
    journeyRef.current = journeyT;
  }, [journeyT]);

  driveToIndexRef.current = (nextIndex) => {
    const clamped = clampStopIndex(nextIndex);
    const targetT = stopIndexToT(clamped);
    const fromT = journeyRef.current;
    if (Math.abs(targetT - fromT) < 0.001) {
      targetIndexRef.current = clamped;
      setTargetIndex(clamped);
      applyPassMovement(fromT, targetT, passCountsRef.current);
      setRawT(targetT);
      setJourneyT(targetT);
      setMoving(false);
      movingRef.current = false;
      return;
    }

    if (animationRef.current) cancelAnimationFrame(animationRef.current);
    targetIndexRef.current = clamped;
    setTargetIndex(clamped);
    setDismissedLocId(null);
    setMoving(true);
    movingRef.current = true;

    const distance = Math.abs(targetT - fromT);
    // Half-speed (i.e. 2× duration) for a more grounded, lived-in pace.
    const duration = Math.max(5400, Math.min(9600, distance * 24800));
    let startedAt = null;

    const tick = (now) => {
      if (startedAt === null) startedAt = now;
      const u = Math.min(1, (now - startedAt) / duration);
      const nextT = fromT + (targetT - fromT) * u;
      // Mutate pass counts BEFORE advancing journeyRef so the segment
      // computation uses the previous position as `lastT`.
      applyPassMovement(journeyRef.current, nextT, passCountsRef.current);
      journeyRef.current = nextT;
      setRawT(nextT);
      setJourneyT(nextT);
      if (u < 1) {
        animationRef.current = requestAnimationFrame(tick);
      } else {
        animationRef.current = 0;
        applyPassMovement(journeyRef.current, targetT, passCountsRef.current);
        journeyRef.current = targetT;
        setRawT(targetT);
        setJourneyT(targetT);
        setMoving(false);
        movingRef.current = false;
      }
    };
    animationRef.current = requestAnimationFrame(tick);
  };

  useEffect(() => () => {
    if (animationRef.current) cancelAnimationFrame(animationRef.current);
  }, []);

  // Active location appears only after the rover has fully docked at a stop.
  const activeLoc = useMemo(() => {
    if (moving || targetIndex < 0) return null;
    return LOCATIONS[targetIndex] || null;
  }, [moving, targetIndex]);

  useEffect(() => {
    setDismissedLocId(null);
  }, [activeLoc?.id]);

  const dockedJourneyT = journeyT;

  // ------------------- compute rover position ------------------
  const roverPos = useMemo(() => {
    if (!pathLen || !pathRef.current) return { x: START.x * MAP_W, y: START.y * MAP_H, angle: 0 };
    const L = pathLen * dockedJourneyT;
    const p = pathRef.current.getPointAtLength(L);
    const pAhead = pathRef.current.getPointAtLength(Math.min(pathLen, L + 4));
    const angle = Math.atan2(pAhead.y - p.y, pAhead.x - p.x) * 180 / Math.PI;
    return { x: p.x, y: p.y, angle };
  }, [dockedJourneyT, pathLen]);

  // ------------------- layout: cover-fit map ------------------
  // Desktop: cover-fit (fills viewport, may crop edges).
  // Mobile: scale-to-height so map fills vertically; then pan horizontally to follow rover.
  let scale, offsetX, offsetY;
  const mobileCardVisible = isMobile && activeLoc && dismissedLocId !== activeLoc.id;
  if (isMobile) {
    scale = viewport.h / MAP_H;             // map fills vertical viewport
    const dispW = MAP_W * scale;
    // Rover's current x in scaled map pixels (roverPos.x is in MAP_W coords).
    const roverPxX = (roverPos?.x ?? START.x * MAP_W) * scale;
    // Pan so rover sits ~45% from left edge.
    let pan = viewport.w * 0.45 - roverPxX;
    pan = Math.min(0, Math.max(viewport.w - dispW, pan));
    offsetX = pan;
    offsetY = 0;
    if (mobileCardVisible) {
      const targetY = viewport.h * 0.33;
      const desiredY = targetY - activeLoc.y * MAP_H * scale;
      offsetY = Math.min(0, Math.max(-viewport.h * 0.30, desiredY));
    }
  } else {
    scale = Math.max(viewport.w / MAP_W, viewport.h / MAP_H);
    offsetX = (viewport.w - MAP_W * scale) / 2;
    offsetY = (viewport.h - MAP_H * scale) / 2;
  }
  const displayW = MAP_W * scale;
  const displayH = MAP_H * scale;

  // Distance days to 2030-01-01 (for HUD flavor)
  const daysTo2030 = useMemo(() => {
    const now = new Date();
    const target = new Date("2030-01-01T00:00:00");
    return Math.max(0, Math.floor((target - now) / 86400000));
  }, []);

  // Prefetch heavy children + hero image so navigation to a building feels instant.
  useEffect(() => {
    const links = [];
    const add = (rel, href, as) => {
      const l = document.createElement("link");
      l.rel = rel;
      l.href = href;
      if (as) l.as = as;
      document.head.appendChild(l);
      links.push(l);
    };
    add("prefetch", "radar-station.jsx", "fetch");
    add("preload",  "assets/radar-scene-v2.webp", "image");
    add("prefetch", "radar-station.html", "document");
    add("prefetch", "archive-station.jsx", "fetch");
    add("prefetch", "archive.html", "document");
    return () => { links.forEach(l => { try { l.remove(); } catch(e) {} }); };
  }, []);

  // Cinematic transition state when entering a building.
  // { id, x, y } — building's screen coords at moment of click.
  const [transition, setTransition] = useState(null);
  const navigate = (loc) => {
    if (loc.locked) return;   // locked buildings don't navigate
    const screenX = offsetX + loc.x * MAP_W * scale;
    const screenY = offsetY + loc.y * MAP_H * scale;
    try { sessionStorage.setItem("inkland.mapStopIndex", String(targetIndexRef.current)); } catch(e) {}
    try { sessionStorage.setItem("inkland.mapPassCounts", passCountsToString(passCountsRef.current)); } catch(e) {}
    setTransition({ id: loc.id, x: screenX, y: screenY });
    // Navigate after the zoom-in finishes
    setTimeout(() => {
      if (loc.id === "radar") {
        window.location.href = "radar-station.html";
      } else if (loc.id === "archive") {
        window.location.href = "archive.html";
      } else {
        // For unbuilt locations, just clear transition
        setTransition(null);
        alert(loc.name + " · 详情页待开发");
      }
    }, 750);
  };

  // Total recorded across locations (skips locations without explicit counts)
  const recorded = useMemo(() => {
    const done = LOCATIONS.reduce((s,l)=>s+(l.count?.done||0),0);
    const total = LOCATIONS.reduce((s,l)=>s+(l.count?.total||0),0);
    return { done, total };
  }, []);

  // ------------------- render ------------------
  return (
    <div
      ref={stageRef}
      className="paper-noise"
      style={{
        position: "fixed",
        inset: 0,
        overflow: "hidden",
        background: "var(--paper)",
      }}
    >
      {/* Map + SVG overlay, cover-fit */}
      <div
        style={{
          position: "absolute",
          left: offsetX, top: offsetY,
          width: displayW, height: displayH,
          transform: tweaks.paperBreath ? "scale(var(--breath, 1))" : "none",
          transformOrigin: "center center",
          animation: tweaks.paperBreath ? "breath 9s ease-in-out infinite" : "none",
        }}
      >
        {/* Base map image — WebP first (~290 KB), PNG fallback (~2.9 MB) */}
        <picture>
          <source srcSet="assets/map-base.webp" type="image/webp" />
          <img
            src="assets/map-base.png"
            alt=""
            draggable={false}
            fetchpriority="high"
            decoding="async"
            style={{
              position: "absolute", inset: 0,
              width: "100%", height: "100%",
              filter: "contrast(1.02) saturate(0.9)",
              userSelect: "none",
            }}
          />
        </picture>

        {/* SVG overlay with path + rover + HUD rings */}
        <svg
          ref={svgRef}
          viewBox={`0 0 ${MAP_W} ${MAP_H}`}
          preserveAspectRatio="xMidYMid slice"
          style={{ position: "absolute", inset: 0, width: "100%", height: "100%", overflow: "visible" }}
        >
          <defs>
            {/* Very soft golden glow */}
            <radialGradient id="glowBurn" cx="50%" cy="50%" r="50%">
              <stop offset="0%" stopColor="#C17A3A" stopOpacity="0.35"/>
              <stop offset="60%" stopColor="#E8B86A" stopOpacity="0.12"/>
              <stop offset="100%" stopColor="#E8B86A" stopOpacity="0"/>
            </radialGradient>
            <filter id="softInk" x="-20%" y="-20%" width="140%" height="140%">
              <feGaussianBlur stdDeviation="0.4"/>
            </filter>
            {/* Rough hand-drawn displacement for path */}
            <filter id="rough" x="-5%" y="-5%" width="110%" height="110%">
              <feTurbulence type="fractalNoise" baseFrequency="0.012" numOctaves="2" seed="4"/>
              <feDisplacementMap in="SourceGraphic" scale="3"/>
            </filter>
          </defs>

          {/* BASE PATH — faintly visible so the route is hinted at */}
          <path
            d={PATH_D}
            stroke="#2C2416"
            strokeOpacity={tweaks.pathVisible}
            strokeWidth={2.2}
            strokeDasharray="1 6"
            strokeLinecap="round"
            fill="none"
            filter="url(#rough)"
          />

          {/* Hidden reference path for length calculations & tracks */}
          <path
            ref={pathRef}
            d={PATH_D}
            stroke="none"
            fill="none"
          />

          {/* TIRE TRACKS — three stacked layers, one per pass-count threshold.
              Each segment of the path keeps its own pass count (see
              passCountsRef in Stage). A segment crossed once shows the base
              tread; crossed twice gets a second darker layer on top; crossed
              three or more times gets a third. Counts only ever increase, so
              backing up never erases tracks — it deepens them. */}
          {pathLen > 0 && <TireTracks
            pathD={PATH_D}
            pathLen={pathLen}
            journeyT={dockedJourneyT}
            passCounts={passCountsRef.current}
            opacity={tweaks.trackOpacity}
          />}

          {/* Building HUD activation layers */}
          {LOCATIONS.map(loc => (
            <BuildingHUD
              key={loc.id}
              loc={loc}
              active={activeLoc?.id === loc.id}
              journeyT={dockedJourneyT}
            />
          ))}

          {/* Rover + astronaut */}
          <RoverSprite x={roverPos.x} y={roverPos.y} angle={roverPos.angle} moving={moving} />
        </svg>
      </div>

      {/* Active building info card (HTML layer for readability) */}
      {activeLoc && dismissedLocId !== activeLoc.id && (
        <BuildingCard
          loc={activeLoc}
          viewport={viewport}
          isMobile={isMobile}
          mapOffset={{ x: offsetX, y: offsetY, scale }}
          onEnter={navigate}
          onDismiss={() => {
            // On mobile, closing the card never auto-drives to the next stop —
            // the user might be on their way back. They advance with their own
            // swipe gesture. Desktop keeps the wheel-driven flow as before.
            if (isMobile) {
              setDismissedLocId(activeLoc.id);
              return;
            }
            if (targetIndexRef.current < LOCATIONS.length - 1) {
              driveToIndexRef.current?.(targetIndexRef.current + 1);
            } else {
              setDismissedLocId(activeLoc.id);
            }
          }}
        />
      )}

      {/* HUD four corners */}
      {tweaks.showHUD && <HUD
        journeyT={dockedJourneyT}
        rawT={rawT}
        activeLoc={activeLoc}
        recorded={recorded}
        daysTo2030={daysTo2030}
        isMobile={isMobile}
      />}

      {/* Hint: scroll */}
      <ScrollHint visible={rawT < 0.02} isMobile={isMobile} />

      {/* Breath keyframes */}
      <style>{`
        @keyframes breath {
          0%, 100% { --breath: 1.000; transform: scale(1.000); }
          50%      { --breath: 1.006; transform: scale(1.006); }
        }
        @keyframes captureRing {
          0%   { transform: scale(1.55); opacity: 0; }
          40%  { opacity: 0.85; }
          100% { transform: scale(1.0);  opacity: 0; }
        }
        @keyframes waveScroll {
          from { stroke-dashoffset: 0; }
          to   { stroke-dashoffset: -80; }
        }
        @keyframes cardIn {
          from { opacity: 0; transform: translate(-50%, -48%) scale(0.98); }
          to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
        }
        @keyframes textFadeIn {
          from { opacity: 0; letter-spacing: 0.4em; }
          to   { opacity: 1; letter-spacing: 0.14em; }
        }
        @keyframes sheetUp {
          from { opacity: 0; transform: translateY(40px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes hintPulse {
          0%, 100% { opacity: 0.42; transform: translateY(0); }
          50%      { opacity: 1; transform: translateY(5px); }
        }
        @keyframes wheelTravel {
          0%   { transform: translateY(0); opacity: 0; }
          20%  { opacity: 1; }
          80%  { opacity: 1; }
          100% { transform: translateY(13px); opacity: 0; }
        }
        /* Vertical bob — the primary "ride" feel. Pure Y, no X.
           One deeper sink at 35% mimics a wheel finding a rut, then a
           soft recovery wobble. ease-in-out keeps the motion weighted. */
        @keyframes roverBob {
          0%   { transform: translateY(0); }
          22%  { transform: translateY(-1.30px); }
          35%  { transform: translateY(-2.40px); }   /* deepest sink — the "bump" beat */
          50%  { transform: translateY(0.55px); }    /* small overshoot landing */
          66%  { transform: translateY(-0.95px); }
          82%  { transform: translateY(0.20px); }
          100% { transform: translateY(0); }
        }
        /* Slow chassis roll — pure rotation, no translation. Period chosen
           so 720 : 1080 = 2 : 3 against the bob; the layered motion never
           lands on the same frame, giving an organic non-repeating feel. */
        @keyframes roverRoll {
          0%   { transform: rotate(0deg); }
          33%  { transform: rotate(-0.65deg); }
          66%  { transform: rotate(0.50deg); }
          100% { transform: rotate(0deg); }
        }
      `}</style>

      {/* Cinematic transition overlay — zoom toward the building when entering */}
      {transition && (
        <TransitionOverlay
          screenX={transition.x}
          screenY={transition.y}
        />
      )}

      {/* Tweaks panel */}
      {tweaks.showTweaks && (
        <TweaksPanel title="TWEAKS · PHASE I">
          <TweakSection title="Visual">
            <TweakSlider label="Path visibility" value={tweaks.pathVisible} min={0.05} max={0.7} step={0.01} onChange={v=>setTweak("pathVisible", v)} />
            <TweakSlider label="Track opacity" value={tweaks.trackOpacity} min={0.15} max={1} step={0.02} onChange={v=>setTweak("trackOpacity", v)} />
            <TweakToggle label="Paper breath" checked={tweaks.paperBreath} onChange={v=>setTweak("paperBreath", v)} />
            <TweakToggle label="HUD visible" checked={tweaks.showHUD} onChange={v=>setTweak("showHUD", v)} />
          </TweakSection>
        </TweaksPanel>
      )}
    </div>
  );
}

/* ========= Sub-components ========= */

function TireTracks({ pathD, pathLen, journeyT, passCounts, opacity }) {
  // The path is divided into N_SEGMENTS pieces; each piece has a pass count
  // (how many times the rover has crossed it). We render up to three stacked
  // tread layers — one per threshold (>= 1, >= 2, >= 3 passes). Each layer's
  // mask uses a strokeDasharray pattern that reveals exactly the segments
  // meeting its threshold. Stacked dashes accumulate visual depth, so a
  // segment crossed three times naturally appears the darkest.
  const pathElRef = useRef(null);
  const maskIdL1 = useMemo(() => `tread-mask-l1-${Math.random().toString(36).slice(2)}`, []);
  const maskIdL2 = useMemo(() => `tread-mask-l2-${Math.random().toString(36).slice(2)}`, []);
  const maskIdL3 = useMemo(() => `tread-mask-l3-${Math.random().toString(36).slice(2)}`, []);
  const [tracks, setTracks] = useState({ left: "", right: "", total: 0 });

  useEffect(() => {
    if (!pathElRef.current) return;
    const path = pathElRef.current;
    const total = path.getTotalLength();
    const GAUGE = 8.4;
    const STEP = 2.8;
    const leftPts = [];
    const rightPts = [];
    for (let L = 0; L <= total; L += STEP) {
      const p = path.getPointAtLength(L);
      const pA = path.getPointAtLength(Math.min(total, L + 0.5));
      const tx = pA.x - p.x, ty = pA.y - p.y;
      const mag = Math.hypot(tx, ty) || 1;
      const nx = -ty / mag, ny = tx / mag;
      leftPts.push({ x: p.x + nx * GAUGE, y: p.y + ny * GAUGE });
      rightPts.push({ x: p.x - nx * GAUGE, y: p.y - ny * GAUGE });
    }
    const toPath = (pts) => pts.map((s, i) => (i === 0 ? "M" : "L") + ` ${s.x.toFixed(1)} ${s.y.toFixed(1)}`).join(" ");
    const leftD = toPath(leftPts);
    const rightD = toPath(rightPts);
    setTracks({ left: leftD, right: rightD, total });
  }, [pathD, pathLen]);

  // Build the dash patterns for each threshold from the current pass counts.
  // Recomputed every render — cheap (O(N) per layer with N=240).
  // `journeyT` is included as a dep-by-render so motion triggers updates.
  const segLen = pathLen > 0 && passCounts ? pathLen / passCounts.length : 0;
  const dashL1 = (passCounts && segLen > 0) ? buildPassThresholdDasharray(passCounts, 1, segLen, pathLen) : null;
  const dashL2 = (passCounts && segLen > 0) ? buildPassThresholdDasharray(passCounts, 2, segLen, pathLen) : null;
  const dashL3 = (passCounts && segLen > 0) ? buildPassThresholdDasharray(passCounts, 3, segLen, pathLen) : null;

  // Only render layer N if any segment has reached that count.
  let maxCount = 0;
  if (passCounts) {
    for (let i = 0; i < passCounts.length; i++) {
      if (passCounts[i] > maxCount) maxCount = passCounts[i];
    }
  }
  const hasL1 = maxCount >= 1;
  const hasL2 = maxCount >= 2;
  const hasL3 = maxCount >= 3;

  // Suppress unused-var warning when journeyT is a re-render trigger only.
  void journeyT;

  return (
    <g style={{ mixBlendMode: "multiply" }} opacity={opacity}>
      <path ref={pathElRef} d={pathD} fill="none" stroke="none" />

      {/* Mask per threshold — reveals only segments with count >= N. */}
      {hasL1 && (
        <mask id={maskIdL1} maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
          <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="black" />
          <path d={pathD} stroke="white" strokeWidth={58} strokeLinecap="round"
                fill="none" strokeDasharray={dashL1} />
        </mask>
      )}
      {hasL2 && (
        <mask id={maskIdL2} maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
          <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="black" />
          <path d={pathD} stroke="white" strokeWidth={58} strokeLinecap="round"
                fill="none" strokeDasharray={dashL2} />
        </mask>
      )}
      {hasL3 && (
        <mask id={maskIdL3} maskUnits="userSpaceOnUse" x="0" y="0" width={MAP_W} height={MAP_H}>
          <rect x="0" y="0" width={MAP_W} height={MAP_H} fill="black" />
          <path d={pathD} stroke="white" strokeWidth={58} strokeLinecap="round"
                fill="none" strokeDasharray={dashL3} />
        </mask>
      )}

      {/* Soft ground disturbance — visible everywhere the rover has been.
          One layer at base intensity, plus optional layers for retraced. */}
      {hasL1 && tracks.total > 0 && (
        <path d={pathD} stroke="#2C2416" strokeOpacity={0.035} strokeWidth={13}
              strokeLinecap="round" fill="none" filter="url(#softInk)"
              strokeDasharray={dashL1} />
      )}
      {hasL2 && tracks.total > 0 && (
        <path d={pathD} stroke="#2C2416" strokeOpacity={0.045} strokeWidth={15}
              strokeLinecap="round" fill="none" filter="url(#softInk)"
              strokeDasharray={dashL2} />
      )}
      {hasL3 && tracks.total > 0 && (
        <path d={pathD} stroke="#2C2416" strokeOpacity={0.055} strokeWidth={17}
              strokeLinecap="round" fill="none" filter="url(#softInk)"
              strokeDasharray={dashL3} />
      )}

      {/* LAYER 1 (count >= 1): standard tread, painted everywhere visited. */}
      {hasL1 && tracks.left && (
        <path d={tracks.left} stroke="#2C2416" strokeOpacity={0.58} strokeWidth={2.0}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" mask={`url(#${maskIdL1})`} />
      )}
      {hasL1 && tracks.right && (
        <path d={tracks.right} stroke="#2C2416" strokeOpacity={0.58} strokeWidth={2.0}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" strokeDashoffset="5" mask={`url(#${maskIdL1})`} />
      )}

      {/* LAYER 2 (count >= 2): stacks on top — segments retraced once. */}
      {hasL2 && tracks.left && (
        <path d={tracks.left} stroke="#2C2416" strokeOpacity={0.55} strokeWidth={2.3}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" mask={`url(#${maskIdL2})`} />
      )}
      {hasL2 && tracks.right && (
        <path d={tracks.right} stroke="#2C2416" strokeOpacity={0.55} strokeWidth={2.3}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" strokeDashoffset="5" mask={`url(#${maskIdL2})`} />
      )}

      {/* LAYER 3 (count >= 3): deepest — segments retraced two or more times. */}
      {hasL3 && tracks.left && (
        <path d={tracks.left} stroke="#2C2416" strokeOpacity={0.55} strokeWidth={2.6}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" mask={`url(#${maskIdL3})`} />
      )}
      {hasL3 && tracks.right && (
        <path d={tracks.right} stroke="#2C2416" strokeOpacity={0.55} strokeWidth={2.6}
              strokeLinecap="round" strokeLinejoin="round" fill="none"
              strokeDasharray="3 7" strokeDashoffset="5" mask={`url(#${maskIdL3})`} />
      )}
    </g>
  );
}

function BuildingHUD({ loc, active, journeyT }) {
  const cx = loc.x * MAP_W;
  const cy = loc.y * MAP_H;

  // Pre-arrival hint: a faint dashed circle that strengthens as we approach.
  if (!active) {
    const dist = Math.abs(journeyT - loc.tProgress);
    const hint = Math.max(0, Math.min(1, 1 - dist / 0.12));
    if (hint < 0.05) return null;
    return (
      <g opacity={hint * 0.3}>
        <circle cx={cx} cy={cy} r={68} fill="none" stroke="#7E8482"
                strokeWidth="1" strokeDasharray="1 4" />
      </g>
    );
  }

  // When active flips on, the entire group re-mounts and the staged CSS
  // entry animations play once. Persistent breathing is on inner elements.
  return <CalibrateHUD cx={cx} cy={cy} order={loc.order} />;
}

/* ------------------------------------------------------------------ */
/* CalibrateHUD: "Sync calibration" overlay drawn on the paper map.    */
/* Style brief: thin silver-grey lines, sparse old-copper accents,     */
/* multiply blend so it reads as instrument layer over hand-drawn map. */
/* ------------------------------------------------------------------ */
const HUD_COLORS = {
  silverStrong: "rgba(126, 132, 130, 0.78)",
  silverMid:    "rgba(148, 151, 146, 0.50)",
  silverSoft:   "rgba(170, 169, 160, 0.26)",
  ink:          "rgba(44, 36, 22, 0.82)",
  inkSoft:      "rgba(44, 36, 22, 0.55)",
  copper:       "rgba(193, 122, 58, 0.82)",
  copperSoft:   "rgba(193, 122, 58, 0.32)",
};

function CalibrateHUD({ cx, cy, order }) {
  const RING_R = 95;          // outer ring radius (190px diameter)
  const INNER_DASH_R = 76;    // dashed inner ring
  const INNER_SOLID_R = 58;   // small solid inner ring

  // SYNC progress: animate from 0 → 100 over ~880ms after a 260ms initial delay.
  const [sync, setSync] = useState(0);
  useEffect(() => {
    const T = 880;
    const t0 = performance.now();
    let raf;
    const tick = (now) => {
      const t = Math.min(1, (now - t0 - 260) / T);
      const eased = t <= 0 ? 0 : 1 - Math.pow(1 - t, 3);
      setSync(Math.round(eased * 100));
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  return (
    <g style={{ mixBlendMode: "multiply" }}>
      {/* Subtle warm halo behind the building — keeps it legible on the paper. */}
      <circle cx={cx} cy={cy} r={RING_R * 1.85} fill="url(#glowBurn)" opacity="0.55" />

      {/* ===== CENTER RING (entry: scale 0.66 → 1, opacity 0 → 1) ===== */}
      <g style={{
        transformOrigin: `${cx}px ${cy}px`,
        transformBox: "fill-box",
        animation: "hudRingIn 520ms cubic-bezier(.2,.8,.2,1) both",
      }}>
        {/* Soft outermost halo ring — pulses */}
        <circle cx={cx} cy={cy} r={RING_R + 6} fill="none"
                stroke={HUD_COLORS.silverSoft} strokeWidth="1"
                style={{
                  transformOrigin: `${cx}px ${cy}px`,
                  transformBox: "fill-box",
                  animation: "hudHalo 3.4s ease-in-out infinite",
                }}/>

        {/* Outer ring */}
        <circle cx={cx} cy={cy} r={RING_R} fill="none"
                stroke={HUD_COLORS.silverStrong} strokeWidth="1" />

        {/* Inner dashed ring */}
        <circle cx={cx} cy={cy} r={INNER_DASH_R} fill="none"
                stroke={HUD_COLORS.silverMid} strokeWidth="0.7"
                strokeDasharray="2 5" />

        {/* Inner small solid ring */}
        <circle cx={cx} cy={cy} r={INNER_SOLID_R} fill="none"
                stroke={HUD_COLORS.silverStrong} strokeWidth="0.6"
                strokeOpacity="0.85" />

        {/* Cross ticks at N/E/S/W on outer ring */}
        {[0, 90, 180, 270].map(deg => {
          const a = (deg * Math.PI) / 180;
          const r1 = RING_R - 6, r2 = RING_R + 6;
          const x1 = cx + Math.cos(a) * r1, y1 = cy + Math.sin(a) * r1;
          const x2 = cx + Math.cos(a) * r2, y2 = cy + Math.sin(a) * r2;
          return <line key={deg} x1={x1} y1={y1} x2={x2} y2={y2}
                       stroke={HUD_COLORS.ink} strokeWidth="1" />;
        })}

        {/* Sparse degree ticks every 30° (skip cardinals) */}
        {Array.from({length: 12}).map((_, i) => {
          if (i % 3 === 0) return null;
          const a = (i * 30 * Math.PI) / 180;
          const r1 = RING_R + 1, r2 = RING_R + 4;
          const x1 = cx + Math.cos(a) * r1, y1 = cy + Math.sin(a) * r1;
          const x2 = cx + Math.cos(a) * r2, y2 = cy + Math.sin(a) * r2;
          return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2}
                       stroke={HUD_COLORS.silverMid} strokeWidth="0.6" />;
        })}

        {/* Tiny copper notch at top-of-ring (fixed reference index) */}
        <line x1={cx} y1={cy - RING_R - 8} x2={cx} y2={cy - RING_R - 14}
              stroke={HUD_COLORS.copper} strokeWidth="1.2" />

        {/* Crosshair at center */}
        <line x1={cx-3} y1={cy} x2={cx+3} y2={cy} stroke={HUD_COLORS.copper} strokeWidth="0.9"/>
        <line x1={cx} y1={cy-3} x2={cx} y2={cy+3} stroke={HUD_COLORS.copper} strokeWidth="0.9"/>
        <circle cx={cx} cy={cy} r="1.2" fill={HUD_COLORS.copper}/>
      </g>

      {/* ===== TOP TITLE: dot + CALIBRATE SIGNAL ===== */}
      <g style={{ animation: "hudFadeUp 420ms cubic-bezier(.2,.8,.2,1) 90ms both" }}>
        <circle cx={cx} cy={cy - RING_R - 32} r="3" fill={HUD_COLORS.ink} />
        <text x={cx} y={cy - RING_R - 14} textAnchor="middle"
              fontFamily="JetBrains Mono, monospace" fontSize="11"
              letterSpacing="3.5"
              fill={HUD_COLORS.ink}>
          CALIBRATE SIGNAL
        </text>
      </g>

      {/* ===== LEFT WAVEFORM (RECEIVED) ===== */}
      <g style={{ animation: "hudFade 380ms ease-out 140ms both" }}>
        <Waveform
          cx={cx - RING_R - 110} cy={cy}
          width={170} height={50}
          color={HUD_COLORS.silverStrong}
          freq={9} seed={1.4}
        />
        <text x={cx - RING_R - 110} y={cy + 36} textAnchor="middle"
              fontFamily="JetBrains Mono, monospace" fontSize="8"
              letterSpacing="3"
              fill={HUD_COLORS.ink}>
          RECEIVED
        </text>
      </g>

      {/* ===== RIGHT WAVEFORM (TARGET — copper tint) ===== */}
      <g style={{ animation: "hudFade 380ms ease-out 140ms both" }}>
        <Waveform
          cx={cx + RING_R + 110} cy={cy}
          width={170} height={50}
          color={HUD_COLORS.copper}
          freq={11} seed={3.7}
        />
        <text x={cx + RING_R + 110} y={cy + 36} textAnchor="middle"
              fontFamily="JetBrains Mono, monospace" fontSize="8"
              letterSpacing="3"
              fill={HUD_COLORS.ink}>
          TARGET
        </text>
      </g>

      {/* ===== TUNING DIAL (bottom-right of ring) ===== */}
      <g style={{ animation: "hudFadeUp 420ms cubic-bezier(.2,.8,.2,1) 120ms both" }}>
        <TuningDial cx={cx + 92} cy={cy + 38} r={26} />
        <text x={cx + 92} y={cy + 78} textAnchor="middle"
              fontFamily="JetBrains Mono, monospace" fontSize="7"
              letterSpacing="2.5"
              fill={HUD_COLORS.inkSoft}>
          HOLD TO TUNE
        </text>
      </g>

      {/* ===== SYNC PROGRESS BAR (below ring) ===== */}
      <g style={{ animation: "hudFadeUp 380ms ease-out 180ms both" }}>
        {(() => {
          const barW = 130;
          const barX = cx - barW / 2;
          const barY = cy + RING_R + 26;
          const filledW = (sync / 100) * barW;
          const isDone = sync >= 100;
          return (
            <>
              <text x={barX} y={barY - 6}
                    fontFamily="JetBrains Mono, monospace" fontSize="8"
                    letterSpacing="3" fill={HUD_COLORS.ink}>
                SYNC
              </text>
              <text x={barX + barW} y={barY - 6} textAnchor="end"
                    fontFamily="JetBrains Mono, monospace" fontSize="8"
                    letterSpacing="2"
                    fill={isDone ? HUD_COLORS.copper : HUD_COLORS.ink}
                    style={{ fontVariantNumeric: "tabular-nums" }}>
                {sync}%
              </text>
              <rect x={barX} y={barY} width={barW} height="3"
                    fill={HUD_COLORS.silverSoft}/>
              <rect x={barX} y={barY} width={filledW} height="3"
                    fill={isDone ? HUD_COLORS.copper : HUD_COLORS.silverStrong}/>
              <text x={barX} y={barY + 18}
                    fontFamily="JetBrains Mono, monospace" fontSize="8"
                    letterSpacing="3" fill={HUD_COLORS.ink}>
                LOCK · {String(order).padStart(3, "0")}
              </text>
              <circle cx={barX + 64} cy={barY + 14.4} r="3"
                      fill={isDone ? HUD_COLORS.copper : HUD_COLORS.silverMid}
                      style={{ animation: isDone ? "hudPulse 1.6s ease-in-out infinite" : "none" }}/>
            </>
          );
        })()}
      </g>

      {/* ===== KEYFRAMES (scoped within this SVG — fine in inline style) ===== */}
      <style>{`
        @keyframes hudRingIn {
          0%   { opacity: 0; transform: scale(0.66); }
          60%  { opacity: 1; }
          100% { opacity: 1; transform: scale(1); }
        }
        @keyframes hudFadeUp {
          0%   { opacity: 0; transform: translateY(12px); }
          100% { opacity: 1; transform: translateY(0); }
        }
        @keyframes hudFade {
          0%   { opacity: 0; }
          100% { opacity: 1; }
        }
        @keyframes hudHalo {
          0%, 100% { opacity: 0.85; transform: scale(1); }
          50%      { opacity: 0.35; transform: scale(1.045); }
        }
        @keyframes hudPulse {
          0%, 100% { opacity: 1; }
          50%      { opacity: 0.4; }
        }
        @keyframes wfBreath {
          0%, 100% { opacity: 0.88; }
          50%      { opacity: 0.42; }
        }
      `}</style>
    </g>
  );
}

/* Small SVG waveform — slow opacity breath, sine envelope tapers at edges. */
function Waveform({ cx, cy, width, height, color, freq = 10, seed = 0 }) {
  const N = 80;
  const points = [];
  for (let i = 0; i <= N; i++) {
    const t = i / N;
    const x = cx - width / 2 + t * width;
    const env = Math.sin(Math.PI * t);            // taper at both ends
    const y = cy + Math.sin(t * freq + seed) * (height / 2) * env;
    points.push(`${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`);
  }
  const d = points.join(" ");
  return (
    <g style={{ animation: "wfBreath 3.6s ease-in-out infinite" }}>
      <path d={d} fill="none" stroke={color} strokeWidth="0.95" strokeLinecap="round"/>
    </g>
  );
}

/* Tuning dial (bottom-right of ring). */
function TuningDial({ cx, cy, r }) {
  return (
    <g>
      <circle cx={cx} cy={cy} r={r} fill="none" stroke={HUD_COLORS.silverStrong} strokeWidth="0.9"/>
      <circle cx={cx} cy={cy} r={r * 0.66} fill="none" stroke={HUD_COLORS.silverMid} strokeWidth="0.6"/>
      <path d={describeArc(cx, cy, r * 0.84, -45, 75)}
            fill="none" stroke={HUD_COLORS.copper}
            strokeWidth="1.6" strokeLinecap="round"/>
      <circle cx={cx} cy={cy} r="1.5" fill={HUD_COLORS.ink}/>
      {Array.from({length: 12}).map((_, i) => {
        const a = (i * 30 * Math.PI) / 180;
        const r1 = r - 3, r2 = r + 1;
        const x1 = cx + Math.cos(a) * r1, y1 = cy + Math.sin(a) * r1;
        const x2 = cx + Math.cos(a) * r2, y2 = cy + Math.sin(a) * r2;
        return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2}
                     stroke={HUD_COLORS.silverStrong} strokeWidth="0.5"/>;
      })}
    </g>
  );
}
function polarToCartesian(cx, cy, r, deg) {
  const rad = (deg - 90) * Math.PI / 180;
  return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx, cy, r, startDeg, endDeg) {
  const start = polarToCartesian(cx, cy, r, endDeg);
  const end   = polarToCartesian(cx, cy, r, startDeg);
  const large = endDeg - startDeg <= 180 ? 0 : 1;
  return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
}

function RoverSprite({ x, y, angle, moving }) {
  // Rover sprite: top-down illustration. Image's "front" points up (y-),
  // so we add +90° to align it with the path tangent (which has angle=0 = +x / east).
  const W = 36;   // visual width on the map (matches the previous SVG placeholder)
  const H = 56;   // height = W × (240/153) to preserve native aspect ratio
  return (
    <g transform={`translate(${x}, ${y}) rotate(${angle + 90})`}>
      {/* Outer layer: slow chassis roll (rotation only, no translation). */}
      <g style={{
        transformBox: "fill-box",
        transformOrigin: "center center",
        animation: moving ? "roverRoll 1080ms ease-in-out infinite" : "none",
      }}>
        {/* Inner layer: primary vertical bob (Y only).
            720ms × 1080ms outer = 2:3 ratio — never repeats. */}
        <g style={{
          transformBox: "fill-box",
          transformOrigin: "center center",
          animation: moving ? "roverBob 720ms ease-in-out infinite" : "none",
        }}>
          {/* soft shadow / ground stain — kept under the new sprite */}
          <ellipse cx="0" cy={H * 0.42} rx={W * 0.55} ry={W * 0.18}
            fill="#2C2416" fillOpacity="0.22" filter="url(#softInk)" />
          <image
            href="assets/rover.png"
            x={-W / 2} y={-H / 2}
            width={W} height={H}
            style={{ imageRendering: "auto" }}
          />
        </g>
      </g>
    </g>
  );
}

function BuildingCard({ loc, viewport, isMobile, mapOffset, onEnter, onDismiss }) {
  const screenX = mapOffset.x + loc.x * MAP_W * mapOffset.scale;
  const screenY = mapOffset.y + loc.y * MAP_H * mapOffset.scale;
  // Resolve entries — supports either a static array or a live getter
  // (e.g. archive entries derived from window.ARCHIVES).
  const entries = (typeof loc.getEntries === "function" ? loc.getEntries() : loc.entries) || [];
  const showCountBadge = loc.count && typeof loc.count.done === "number" && typeof loc.count.total === "number";

  // Measure actual card height so vertical clamping is accurate.
  const cardRef = useRef(null);
  const [cardH, setCardH] = useState(380);
  useLayoutEffect(() => {
    if (cardRef.current) setCardH(cardRef.current.offsetHeight);
  }, [loc.id]);

  // ===== Mobile: bottom sheet — full-width, fixed at bottom of viewport =====
  if (isMobile) {
    return (
      <div style={{
        position: "fixed",
        left: 0, right: 0, bottom: 0,
        maxHeight: loc.locked ? "48vh" : "52vh",
        overflowY: "auto",
        background: "rgba(242,237,228,0.985)",
        borderTop: "1px solid rgba(44,36,22,0.42)",
        boxShadow: "0 -10px 30px -12px rgba(44,36,22,0.4)",
        padding: "12px 18px 20px",
        zIndex: 30,
        pointerEvents: "auto",
        animation: "sheetUp 0.45s cubic-bezier(.2,.7,.2,1) both",
        WebkitOverflowScrolling: "touch",
      }}>
        <div style={{
          display: "grid",
          gridTemplateColumns: "1fr auto 1fr",
          alignItems: "center",
          marginBottom: 12,
        }}>
          <div />
          <button
            type="button"
            aria-label="收起当前地点卡片"
            onClick={onDismiss}
            style={{
              appearance: "none",
              border: 0,
              background: "transparent",
              padding: "8px 18px",
              margin: -8,
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              gap: 5,
              color: "var(--ink-mid)",
              fontFamily: "var(--mono)",
              fontSize: 9,
              letterSpacing: "0.24em",
            }}
          >
            <span style={{
              width: 40, height: 3, borderRadius: 2,
              background: "rgba(44,36,22,0.32)",
            }} />
            <span>收起</span>
          </button>
          <button
            type="button"
            aria-label="关闭当前地点卡片"
            onClick={onDismiss}
            style={{
              justifySelf: "end",
              appearance: "none",
              width: 30,
              height: 30,
              border: "1px solid rgba(44,36,22,0.22)",
              borderRadius: "50%",
              background: "rgba(234,228,216,0.55)",
              color: "var(--ink-mid)",
              fontFamily: "var(--mono)",
              fontSize: 16,
              lineHeight: "28px",
              textAlign: "center",
            }}
          >
            ×
          </button>
        </div>

        <div style={{
          display: "flex", alignItems: "baseline", gap: 10,
          fontFamily: "var(--mono)", fontSize: 10,
          letterSpacing: "0.2em", color: "var(--ink-mid)",
          marginBottom: 6,
        }}>
          <span>LOC · {loc.order}</span>
          <span style={{ color: "var(--burn)" }}>{loc.nameEn}</span>
        </div>

        <div style={{
          fontFamily: "var(--serif)", fontSize: 28, lineHeight: 1.05,
          fontWeight: 500, color: "var(--ink)",
          marginBottom: 10,
        }}>
          {loc.name}
        </div>

        <div style={{
          fontFamily: "var(--sans)", fontSize: 13, lineHeight: 1.6,
          color: "var(--ink)", opacity: 0.85,
          marginBottom: 14,
        }}>
          {loc.blurb}
        </div>

        {loc.locked ? (
          <LockedNote note={loc.lockedNote} count={loc.count} />
        ) : (
          <>
            <div style={{ borderTop: "1px solid rgba(44,36,22,0.18)", paddingTop: 8, marginBottom: 14 }}>
              {entries.map((e,i) => (
                <div key={i} style={{
                  display: "grid", gridTemplateColumns: "52px 1fr",
                  gap: 10, padding: "8px 0",
                  borderBottom: i < entries.length-1 ? "1px dashed rgba(44,36,22,0.15)" : "none",
                }}>
                  <span style={{
                    fontFamily: "var(--mono)", fontSize: 10,
                    color: "var(--ink-mid)", letterSpacing: "0.06em",
                  }}>{e.n}</span>
                  <span style={{
                    fontFamily: "var(--sans)", fontSize: 13,
                    color: "var(--ink)", opacity: 0.92,
                    textWrap: "pretty", lineHeight: 1.45,
                  }}>
                    {e.t}
                    {e.dur ? (
                      <span style={{
                        display: "block",
                        fontFamily: "var(--mono)", fontSize: 10,
                        color: "var(--ink-soft)", letterSpacing: "0.06em",
                        marginTop: 3,
                      }}>{e.dur}</span>
                    ) : null}
                  </span>
                </div>
              ))}
            </div>

            <button
              type="button"
              style={{
                appearance: "none",
                width: "100%",
                border: "1px solid var(--burn)",
                background: "var(--burn)",
                color: "var(--paper)",
                fontFamily: "var(--mono)",
                fontSize: 12,
                letterSpacing: "0.28em",
                padding: "13px 20px",
                cursor: "pointer",
              }}
              onClick={() => onEnter(loc)}
            >
              进入 →
            </button>
          </>
        )}
      </div>
    );
  }

  // ===== Desktop: floating card next to building =====
  // Card sits just outside the HUD ring (~80px radius) on the side with more room.
  const CARD_W = 320;
  const RING_GAP = loc.card?.gap ?? 110;   // distance from building center to card edge
  const MARGIN = 24;                        // viewport edge breathing room

  // Side: explicit override per-location, else pick whichever has more room.
  const toRight = loc.card?.side
    ? loc.card.side === "right"
    : (viewport.w - screenX) >= screenX;

  let left = toRight ? screenX + RING_GAP : screenX - RING_GAP - CARD_W;
  left = Math.max(MARGIN, Math.min(viewport.w - CARD_W - MARGIN, left));

  // Vertical: align card top with building when building is in upper half (pushes card down),
  // align card bottom with building when in lower half (pushes card up). Plus optional override.
  const upperHalf = screenY < viewport.h * 0.5;
  const dy = loc.card?.dy ?? 0;
  let top = upperHalf
    ? screenY - 40 + dy                     // building near top → card extends downward
    : screenY - cardH + 40 + dy;            // building near bottom → card extends upward
  top = Math.max(MARGIN + 60, Math.min(viewport.h - cardH - MARGIN, top));

  // Leader line: from the side of the card facing the building → building center.
  const cardEdgeX = toRight ? left : left + CARD_W;
  const cardEdgeY = Math.max(top + 24, Math.min(top + cardH - 24, screenY));

  return (
    <div
      key={loc.id}
      ref={cardRef}
      style={{
        position: "absolute",
        left, top,
        width: CARD_W,
        pointerEvents: "auto",
        zIndex: 30,
        animation: "cardIn 0.5s cubic-bezier(.2,.7,.2,1) both",
      }}
    >
      {/* leader line drawn in viewport coords */}
      <svg
        width={viewport.w} height={viewport.h}
        style={{
          position: "fixed",
          left: 0, top: 0,
          pointerEvents: "none",
          zIndex: 25,
        }}
      >
        <line
          x1={cardEdgeX} y1={cardEdgeY}
          x2={screenX} y2={screenY}
          stroke="#2C2416" strokeOpacity="0.5" strokeWidth="0.7" strokeDasharray="2 3"
        />
        <circle cx={cardEdgeX} cy={cardEdgeY} r="2" fill="#C17A3A" />
      </svg>

      <div style={{
        background: "rgba(242,237,228,0.96)",
        border: "1px solid rgba(44,36,22,0.42)",
        boxShadow: "0 12px 40px -20px rgba(44,36,22,0.35), 0 1px 0 rgba(44,36,22,0.08) inset",
        padding: "22px 24px 20px",
        position: "relative",
        backdropFilter: "blur(2px)",
      }}>
        <CornerBrackets />
        <button
          type="button"
          aria-label="继续巡航到下一站"
          onClick={onDismiss}
          style={{
            position: "absolute",
            top: 10,
            right: 10,
            appearance: "none",
            width: 28,
            height: 28,
            border: "1px solid rgba(44,36,22,0.20)",
            background: "rgba(234,228,216,0.50)",
            color: "var(--ink-mid)",
            fontFamily: "var(--mono)",
            fontSize: 15,
            lineHeight: "26px",
            textAlign: "center",
            cursor: "pointer",
            transition: "all 0.2s ease",
          }}
          onMouseEnter={e => { e.currentTarget.style.color = "var(--burn)"; e.currentTarget.style.borderColor = "rgba(193,122,58,0.45)"; }}
          onMouseLeave={e => { e.currentTarget.style.color = "var(--ink-mid)"; e.currentTarget.style.borderColor = "rgba(44,36,22,0.20)"; }}
        >
          ×
        </button>

        <div style={{
          fontFamily: "var(--mono)",
          fontSize: 11,
          letterSpacing: "0.2em",
          color: "var(--ink-mid)",
          marginBottom: 8,
        }}>
          LOC · {loc.order}
        </div>

        <div style={{
          fontFamily: "var(--serif)",
          fontSize: 36,
          lineHeight: 1,
          fontWeight: 500,
          letterSpacing: "0.02em",
          color: "var(--ink)",
          marginBottom: 4,
          animation: "textFadeIn 0.7s ease-out both",
        }}>
          {loc.name}
        </div>
        <div style={{
          fontFamily: "var(--mono)",
          fontSize: 10,
          letterSpacing: "0.32em",
          color: "var(--burn)",
          marginBottom: 14,
        }}>
          {loc.nameEn}
        </div>

        <div style={{
          fontFamily: "var(--sans)",
          fontSize: 12.5,
          lineHeight: 1.65,
          color: "var(--ink)",
          opacity: 0.85,
          marginBottom: 16,
          textWrap: "pretty",
        }}>
          {loc.blurb}
        </div>

        {loc.locked ? (
          <LockedNote note={loc.lockedNote} count={loc.count} />
        ) : (
          <>
            <div style={{ borderTop: "1px solid rgba(44,36,22,0.18)", paddingTop: 10, marginBottom: 16 }}>
              {entries.map((e,i) => (
                <div key={i} style={{
                  display: "grid", gridTemplateColumns: "56px 1fr 64px",
                  gap: 10, padding: "8px 0",
                  fontFamily: "var(--mono)", fontSize: 10.5,
                  color: "var(--ink)",
                  borderBottom: i < entries.length-1 ? "1px dashed rgba(44,36,22,0.15)" : "none",
                  letterSpacing: "0.04em",
                  alignItems: "baseline",
                }}>
                  <span style={{ color: "var(--ink-mid)" }}>{e.n}</span>
                  <span style={{ fontFamily: "var(--sans)", fontSize: 12, opacity: 0.9, textWrap: "pretty", lineHeight: 1.4 }}>{e.t}</span>
                  <span style={{ color: "var(--ink-mid)", textAlign: "right", fontSize: 10 }}>{e.dur}</span>
                </div>
              ))}
            </div>

            <div style={{ display: "flex", justifyContent: showCountBadge ? "space-between" : "flex-end", alignItems: "center" }}>
              {showCountBadge ? (
                <div style={{
                  fontFamily: "var(--mono)",
                  fontSize: 10,
                  letterSpacing: "0.18em",
                  color: "var(--ink-mid)",
                }}>
                  {loc.count.unit} · {loc.count.done}/{loc.count.total}
                </div>
              ) : null}
              <button
                type="button"
                style={{
                  appearance: "none",
                  border: "1px solid var(--burn)",
                  background: "transparent",
                  color: "var(--burn)",
                  fontFamily: "var(--mono)",
                  fontSize: 11,
                  letterSpacing: "0.28em",
                  padding: "9px 16px",
                  cursor: "pointer",
                  transition: "all 0.25s ease",
                }}
                onMouseEnter={e => { e.currentTarget.style.background = "var(--burn)"; e.currentTarget.style.color = "var(--paper)"; }}
                onMouseLeave={e => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "var(--burn)"; }}
                onClick={() => onEnter(loc)}
              >
                进入 →
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

function LockedNote({ note, count }) {
  return (
    <>
      {/* Top divider */}
      <div style={{ borderTop: "1px solid rgba(44,36,22,0.18)", paddingTop: 14, marginBottom: 14 }}>
        {/* Status badge row */}
        <div style={{
          display: "flex", alignItems: "center", gap: 10,
          marginBottom: 12,
        }}>
          <span style={{
            display: "inline-flex", alignItems: "center", gap: 6,
            padding: "4px 9px",
            border: "1px dashed rgba(44,36,22,0.45)",
            fontFamily: "var(--mono)",
            fontSize: 9.5,
            letterSpacing: "0.28em",
            color: "var(--ink-mid)",
            background: "rgba(44,36,22,0.04)",
          }}>
            <span style={{
              width: 5, height: 5, borderRadius: "50%",
              background: "var(--ink-mid)",
              animation: "lockedPulse 2.4s ease-in-out infinite",
            }} />
            {note.badge}
          </span>
          <span style={{
            fontFamily: "var(--serif-cn)",
            fontSize: 11,
            letterSpacing: "0.16em",
            color: "var(--ink-mid)",
          }}>
            {note.badgeCn}
          </span>
        </div>

        {/* Note body — handwritten paper feel */}
        <div style={{
          position: "relative",
          padding: "12px 14px 12px 16px",
          background: "rgba(234,228,216,0.55)",
          borderLeft: "2px solid rgba(44,36,22,0.35)",
          fontFamily: "var(--serif-cn)",
          color: "var(--ink)",
          lineHeight: 1.7,
        }}>
          <div style={{
            fontSize: 13,
            fontWeight: 500,
            marginBottom: 6,
            textWrap: "pretty",
          }}>
            {note.heading}
          </div>
          <div style={{
            fontSize: 12.5,
            opacity: 0.85,
            marginBottom: 8,
            textWrap: "pretty",
          }}>
            {note.body}
          </div>
          <div style={{
            fontFamily: "var(--mono)",
            fontSize: 10,
            letterSpacing: "0.10em",
            color: "var(--ink-mid)",
            textAlign: "right",
          }}>
            {note.sign}
          </div>
        </div>
      </div>

      {/* Bottom: muted count + non-clickable status pill */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <div style={{
          fontFamily: "var(--mono)",
          fontSize: 10,
          letterSpacing: "0.18em",
          color: "var(--ink-soft)",
        }}>
          {count.unit} · 待开放
        </div>
        <div style={{
          appearance: "none",
          border: "1px dashed rgba(44,36,22,0.45)",
          background: "transparent",
          color: "var(--ink-mid)",
          fontFamily: "var(--mono)",
          fontSize: 10.5,
          letterSpacing: "0.32em",
          padding: "8px 14px",
          cursor: "not-allowed",
          userSelect: "none",
        }}>
          {note.status}
        </div>
      </div>

      <style>{`
        @keyframes lockedPulse {
          0%, 100% { opacity: 0.35; transform: scale(0.85); }
          50%      { opacity: 1.0;  transform: scale(1.15); }
        }
      `}</style>
    </>
  );
}

function CornerBrackets() {
  const c = "rgba(44,36,22,0.7)";
  const sz = 8;
  const s = { position: "absolute", width: sz, height: sz, borderColor: c, borderStyle: "solid" };
  return (
    <>
      <div style={{ ...s, top: -1, left: -1, borderWidth: "1px 0 0 1px" }} />
      <div style={{ ...s, top: -1, right: -1, borderWidth: "1px 1px 0 0" }} />
      <div style={{ ...s, bottom: -1, left: -1, borderWidth: "0 0 1px 1px" }} />
      <div style={{ ...s, bottom: -1, right: -1, borderWidth: "0 1px 1px 0" }} />
    </>
  );
}

/* Cinematic zoom-into-building overlay.
 * Uses CSS-only animations for performance: the entire viewport scales toward
 * the click point while a circular vignette closes in, fading to dark.
 * The cover layer on the destination page picks up where this leaves off. */
function TransitionOverlay({ screenX, screenY }) {
  const xPct = (screenX / window.innerWidth) * 100;
  const yPct = (screenY / window.innerHeight) * 100;

  return (
    <>
      <style>{`
        @keyframes zoomIn {
          from { transform: scale(1); }
          to   { transform: scale(2.6); }
        }
        @keyframes vignetteClose {
          from { opacity: 0; }
          to   { opacity: 1; }
        }
        @keyframes fadeBlack {
          from { opacity: 0; }
          to   { opacity: 1; }
        }
      `}</style>

      {/* Scale the underlying map toward the click point. We do this by
          adding a transform to a fullscreen overlay that takes a snapshot of nothing —
          instead we scale the page via a sibling transform on a wrapper.
          Simpler: just dim+vignette+blackout. The user already sees the map. */}

      {/* Circular vignette closing in on the building */}
      <div style={{
        position: "fixed", inset: 0,
        zIndex: 900,
        pointerEvents: "auto",
        background: `radial-gradient(circle at ${xPct}% ${yPct}%,
          transparent 0%,
          transparent 8%,
          rgba(20, 16, 11, 0.45) 25%,
          rgba(10, 8, 5, 0.92) 60%,
          #000 100%)`,
        animation: "vignetteClose 0.7s cubic-bezier(.55,.05,.85,.3) forwards",
      }}/>

      {/* Final blackout, slightly delayed so the vignette is felt first */}
      <div style={{
        position: "fixed", inset: 0,
        zIndex: 901,
        pointerEvents: "auto",
        background: `radial-gradient(circle at ${xPct}% ${yPct}%,
          rgba(193, 122, 58, 0.25) 0%,
          rgba(20, 16, 11, 0.85) 30%,
          #000 70%)`,
        opacity: 0,
        animation: "fadeBlack 0.45s ease-in 0.35s forwards",
      }}/>

      {/* "Approaching" hint — fades in mid-transition */}
      <div style={{
        position: "fixed",
        left: 0, right: 0,
        top: "50%",
        transform: "translateY(-50%)",
        zIndex: 902,
        pointerEvents: "none",
        textAlign: "center",
        fontFamily: "var(--mono)",
        fontSize: 12,
        letterSpacing: "0.4em",
        color: "#E8B86A",
        opacity: 0,
        animation: "fadeBlack 0.5s ease-out 0.45s forwards",
      }}>
        正 在 接 入
      </div>
    </>
  );
}

function HUD({ journeyT, rawT, activeLoc, recorded, daysTo2030, isMobile }) {
  const hudBase = {
    position: "absolute",
    fontFamily: "var(--mono)",
    fontSize: 11,
    letterSpacing: "0.18em",
    color: "var(--ink)",
    zIndex: 20,
    pointerEvents: "none",
  };
  const dim = { color: "var(--ink-mid)" };
  const percent = (journeyT*100).toFixed(1);
  const passedT = journeyT * 100;

  return (
    <>
      {/* 左上角：品牌标识 */}
      <div style={{
        ...hudBase,
        top: isMobile ? 14 : 30,
        left: isMobile ? 14 : 38,
        letterSpacing: "normal",
      }}>
        <div style={{
          fontFamily: "var(--serif)",
          fontSize: isMobile ? 17 : 22,
          color: "var(--ink)",
          letterSpacing: isMobile ? "0.04em" : "0.08em",
          lineHeight: 1.15,
          whiteSpace: "nowrap",
        }}>
          可能性未来 <span style={{ ...dim, margin: isMobile ? "0 6px" : "0 8px" }}>|</span> 看见 2030
        </div>
        <div style={{
          fontFamily: "var(--mono)",
          fontSize: isMobile ? 11 : 10,
          color: "var(--ink-mid)",
          letterSpacing: isMobile ? "0.26em" : "0.32em",
          marginTop: isMobile ? 5 : 8,
        }}>
          FUTURE&nbsp;IMPERFECT
        </div>
      </div>

      {/* 右上角：距 2030 */}
      <div style={{
        ...hudBase,
        top: isMobile ? 16 : 34,
        right: isMobile ? 14 : 40,
        textAlign: "right",
        fontSize: isMobile ? 9.5 : 11,
      }}>
        <span style={dim}>距 2030 · {daysTo2030} 天</span>
      </div>

      {/* 左下角：指北针 + 比例尺 — 手机隐藏 */}
      {!isMobile && (
        <div style={{ ...hudBase, bottom: 32, left: 38 }}>
          <svg width="34" height="34" viewBox="0 0 36 36" style={{ display: "block", marginBottom: 8 }}>
            <circle cx="18" cy="18" r="16" fill="none" stroke="#2C2416" strokeWidth="0.8" />
            <path d="M 18 4 L 21 18 L 18 16 L 15 18 Z" fill="#2C2416" />
            <text x="18" y="3" textAnchor="middle" fontSize="6" fontFamily="var(--mono)" fill="#2C2416">N</text>
          </svg>
          <div style={dim}>比例尺 1:25,000</div>
        </div>
      )}

      {/* 右下角：内容记录（已隐藏） */}

      {/* 底部居中：旅程进度条 — 手机端在卡片打开时隐藏，避免叠加 */}
      {!(isMobile && activeLoc) && (
      <div style={{
        position: "absolute",
        bottom: isMobile ? 18 : 36,
        left: "50%", transform: "translateX(-50%)",
        zIndex: 20, pointerEvents: "none",
        width: isMobile ? "calc(100vw - 28px)" : "min(620px, 52vw)",
        fontFamily: "var(--mono)",
        fontSize: isMobile ? 9.5 : 10.5,
        letterSpacing: "0.16em",
        color: "var(--ink)",
        padding: isMobile ? "9px 10px 10px" : "10px 14px 12px",
        background: "rgba(242,237,228,0.54)",
        border: "1px solid rgba(44,36,22,0.16)",
        boxShadow: "0 10px 30px -22px rgba(44,36,22,0.45)",
        backdropFilter: "blur(2px)",
      }}>
        <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, alignItems: "center" }}>
          <span style={{ color: "var(--burn)", letterSpacing: "0.24em" }}>ROVER LOG · {percent}%</span>
          <span style={{ ...dim, letterSpacing: "0.14em" }}>{activeLoc ? `已抵达 · ${activeLoc.name}` : "巡航中"}</span>
        </div>
        <div style={{ position: "relative", height: 3, background: "rgba(44,36,22,0.16)" }}>
          <div style={{
            position: "absolute", left: 0, top: 0, bottom: 0,
            width: `${percent}%`,
            background: "linear-gradient(to right, rgba(44,36,22,0.72), var(--burn))",
            transition: "width 0.18s linear",
          }}/>
          {LOCATIONS.map(l => {
            const isActive = activeLoc?.id === l.id;
            const reached = passedT >= l.tProgress * 100 - 0.1;
            return (
              <div key={l.id} style={{
                position: "absolute",
                left: `${l.tProgress*100}%`,
                top: isActive ? -5 : -3,
                width: isActive ? 12 : 7,
                height: isActive ? 12 : 7,
                borderRadius: "50%",
                border: `1px solid ${isActive ? "var(--burn)" : "rgba(44,36,22,0.45)"}`,
                background: isActive
                  ? "var(--paper)"
                  : (reached ? "var(--ink)" : "rgba(242,237,228,0.85)"),
                boxShadow: isActive ? "0 0 0 3px rgba(193,122,58,0.16)" : "none",
                opacity: isActive ? 1 : (reached ? 0.78 : 0.52),
                transform: "translateX(-50%)",
                transition: "all 0.25s ease",
              }}/>
            );
          })}
        </div>
        {!isMobile && (
          <div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, color: "var(--ink-mid)" }}>
            {LOCATIONS.map(l => (
              <span key={l.id} style={{
                color: activeLoc?.id === l.id ? "var(--burn)" : undefined,
                opacity: activeLoc?.id === l.id ? 1 : 0.72,
                fontWeight: activeLoc?.id === l.id ? 500 : 400,
                transition: "color 0.3s",
              }}>{l.order} {l.name}</span>
            ))}
          </div>
        )}
      </div>
      )}
    </>
  );
}

function ScrollHint({ visible, isMobile }) {
  return (
    <div style={{
      position: "absolute",
      bottom: isMobile ? 96 : 126,
      left: "50%",
      transform: "translateX(-50%)",
      width: isMobile ? "min(300px, calc(100vw - 42px))" : 360,
      padding: isMobile ? "14px 16px 16px" : "16px 20px 18px",
      fontFamily: "var(--mono)",
      fontSize: isMobile ? 10 : 11,
      letterSpacing: "0.18em",
      color: "var(--ink)",
      opacity: visible ? 0.88 : 0,
      transition: "opacity 0.6s",
      pointerEvents: "none",
      textAlign: "center",
      zIndex: 15,
      background: "rgba(242,237,228,0.68)",
      border: "1px solid rgba(44,36,22,0.22)",
      boxShadow: "0 16px 34px -26px rgba(44,36,22,0.5)",
      backdropFilter: "blur(2px)",
    }}>
      <div style={{
        color: "var(--burn)",
        fontSize: 9,
        letterSpacing: "0.34em",
        marginBottom: 8,
      }}>
        ROVER CONTROL · 00
      </div>
      <div style={{
        fontFamily: "var(--serif-cn)",
        fontSize: isMobile ? 16 : 18,
        letterSpacing: "0.08em",
        lineHeight: 1.25,
        marginBottom: 10,
      }}>
        {isMobile ? "上滑一次，启动巡航" : "轻滚一次，启动巡航"}
      </div>
      <div style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        gap: 12,
        color: "var(--ink-mid)",
        fontSize: 9,
        letterSpacing: "0.2em",
      }}>
        <span>{isMobile ? "ONE SWIPE" : "ONE SCROLL"}</span>
        <span style={{
          position: "relative",
          width: isMobile ? 18 : 20,
          height: isMobile ? 36 : 34,
          border: "1px solid rgba(44,36,22,0.45)",
          borderRadius: isMobile ? 9 : 11,
          display: "inline-block",
        }}>
          <span style={{
            position: "absolute",
            left: "50%",
            top: isMobile ? 8 : 6,
            width: isMobile ? 2 : 3,
            height: isMobile ? 11 : 7,
            borderRadius: 2,
            background: "var(--burn)",
            transform: "translateX(-50%)",
            animation: "wheelTravel 1.35s ease-in-out infinite",
          }} />
        </span>
        <span style={{ color: "var(--burn)", animation: "hintPulse 1.35s ease-in-out infinite" }}>↓</span>
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <ErrorBoundary><Stage /></ErrorBoundary>
);
