/**
 * V3 shared game-like scientific visualizations (React 18 + Babel global script).
 * Load after `scenario-model.js`, and expose through `window.V3GameVisuals`.
 *
 * Public components:
 * - <MissionDataStage scenario state ageBand active />
 *   `scenario` is a V3ScenarioModel scenario object (or scenario id).
 *   `state` may contain `{ runState, observation, responses: { markers }, capture }`.
 *   `capture` uses the V3.1 sequence `scanning -> frozen -> reasoned -> locked
 *   -> confirm`, plus `cursorT`, `selectedReasonId`, and `feedback`.
 * - <CompareLens scenario ageBand initialMode="lens" onLensChange? />
 *   Same-scale drag comparison. `initialMode` is `lens` or `overlay`.
 * - <EvidenceReplay scenario markers ageBand durationMs={8000} autoPlay compact? />
 *   Replays the fixed series and reveals evidence at each marker's real sample.
 * - <EvidenceStarOverlay scenario markers points? revealProgress? />
 *   Markers use `sampleT`; frequency markers can fall back to `sampleX`.
 * - <SolarEnergyRiver />, <SatellitePassView />, <RfiSpectrumScout />
 *   Lower-level presentational views. Props: scenario, markers, playhead,
 *   ageBand, revealProgress, capture.
 *
 * Scientific boundary: every glow, band, cursor and star is derived from the
 * fixed scenario series, its numeric axis, playback progress, or a saved marker.
 * Decorative motion never adds a measurement or identifies a radio source.
 */
(function () {
  "use strict";

  const { useEffect, useMemo, useRef, useState } = React;
  const scenarioModel = window.V3ScenarioModel;
  const gameModel = window.V3GameModel || null;

  const CHART = (() => {
    const base = {
      width: 1000,
      height: 500,
      left: 90,
      right: 30,
      top: 42,
      bottom: 82,
    };
    const plotWidth = base.width - base.left - base.right;
    const plotHeight = base.height - base.top - base.bottom;
    return Object.freeze({
      ...base,
      plotWidth,
      plotHeight,
      plotBottom: base.top + plotHeight,
    });
  })();

  let visualId = 0;

  function useVisualId(prefix) {
    const idRef = useRef(null);
    if (!idRef.current) {
      visualId += 1;
      idRef.current = `${prefix}-${visualId}`;
    }
    return idRef.current;
  }

  function clamp(value, min = 0, max = 1) {
    return Math.max(min, Math.min(max, Number(value) || 0));
  }

  function safeSeries(points) {
    if (!Array.isArray(points)) return [];
    return points
      .filter((point) =>
        point && Number.isFinite(Number(point.t)) &&
        Number.isFinite(Number(point.value))
      )
      .map((point) => ({
        t: clamp(point.t),
        value: clamp(point.value, 0, 100),
      }))
      .sort((a, b) => a.t - b.t);
  }

  function resolveScenario(scenario) {
    if (scenario && typeof scenario === "object") return scenario;
    if (typeof scenario === "string" && scenarioModel) {
      return scenarioModel.getScenario(scenario);
    }
    return scenarioModel ? scenarioModel.getScenario("solar-demo-v1") : null;
  }

  function sampleSeries(points, t) {
    if (scenarioModel && typeof scenarioModel.sampleSeries === "function") {
      return scenarioModel.sampleSeries(points, t);
    }
    const series = safeSeries(points);
    if (!series.length) return 0;
    const x = clamp(t);
    if (x <= series[0].t) return series[0].value;
    for (let index = 1; index < series.length; index += 1) {
      const right = series[index];
      if (x <= right.t) {
        const left = series[index - 1];
        const ratio = (x - left.t) / Math.max(0.0001, right.t - left.t);
        return left.value + (right.value - left.value) * ratio;
      }
    }
    return series[series.length - 1].value;
  }

  function average(points) {
    const series = safeSeries(points);
    return series.length
      ? series.reduce((sum, point) => sum + point.value, 0) / series.length
      : 0;
  }

  function chartX(t) {
    return CHART.left + clamp(t) * CHART.plotWidth;
  }

  function chartY(value) {
    return CHART.top + (1 - clamp(value, 0, 100) / 100) * CHART.plotHeight;
  }

  function linePath(points) {
    const series = safeSeries(points);
    return series.map((point, index) =>
      `${index ? "L" : "M"}${chartX(point.t).toFixed(2)},${
        chartY(point.value).toFixed(2)
      }`
    ).join(" ");
  }

  function areaPath(points) {
    const series = safeSeries(points);
    if (!series.length) return "";
    return `${linePath(series)} L${
      chartX(series[series.length - 1].t)
    },${CHART.plotBottom} L${chartX(series[0].t)},${CHART.plotBottom} Z`;
  }

  function differencePath(first, second) {
    const base = safeSeries(first);
    if (!base.length) return "";
    const upper = base.map((point) => ({
      t: point.t,
      value: sampleSeries(second, point.t),
    }));
    const upperPath = upper.map((point, index) =>
      `${index ? "L" : "M"}${chartX(point.t).toFixed(2)},${
        chartY(point.value).toFixed(2)
      }`
    ).join(" ");
    const lowerPath = base.slice().reverse().map((point) =>
      `L${chartX(point.t).toFixed(2)},${chartY(point.value).toFixed(2)}`
    ).join(" ");
    return `${upperPath} ${lowerPath} Z`;
  }

  function rollingMean(points, radius = 3) {
    const series = safeSeries(points);
    return series.map((point, index) => {
      const start = Math.max(0, index - radius);
      const end = Math.min(series.length, index + radius + 1);
      const windowPoints = series.slice(start, end);
      return {
        t: point.t,
        value: windowPoints.reduce((sum, item) => sum + item.value, 0) /
          windowPoints.length,
      };
    });
  }

  function xAxisTicks(scenario, ageBand) {
    const xAxis = scenario && scenario.observe && scenario.observe.xAxis
      ? scenario.observe.xAxis
      : { kind: "time" };
    if (
      xAxis.kind === "frequency" && Number.isFinite(Number(xAxis.min)) &&
      Number.isFinite(Number(xAxis.max))
    ) {
      const min = Number(xAxis.min);
      const max = Number(xAxis.max);
      const ticks = Array.isArray(xAxis.ticks) && xAxis.ticks.length
        ? xAxis.ticks
        : [min, (min + max) / 2, max];
      return ticks.map((tick) => ({
        t: clamp((Number(tick) - min) / Math.max(1, max - min)),
        label: `${Math.round(Number(tick))}`,
      }));
    }
    return ageBand === "9-12"
      ? [
        { t: 0, label: "0%" },
        { t: 0.25, label: "25%" },
        { t: 0.5, label: "50%" },
        { t: 0.75, label: "75%" },
        { t: 1, label: "100%" },
      ]
      : [{ t: 0, label: "开始" }, { t: 0.5, label: "中段" }, {
        t: 1,
        label: "结束",
      }];
  }

  function axisCaption(scenario) {
    const xAxis = scenario && scenario.observe && scenario.observe.xAxis
      ? scenario.observe.xAxis
      : { kind: "time", label: "观测进度" };
    return xAxis.kind === "frequency"
      ? `${xAxis.label || "频率"} / ${xAxis.unit || "MHz"}`
      : `${xAxis.label || "观测进度"} →`;
  }

  function markerT(marker, scenario) {
    if (marker && Number.isFinite(Number(marker.sampleT))) {
      return clamp(Number(marker.sampleT));
    }
    const xAxis = scenario && scenario.observe && scenario.observe.xAxis
      ? scenario.observe.xAxis
      : null;
    const min = xAxis ? Number(xAxis.min) : NaN;
    const max = xAxis ? Number(xAxis.max) : NaN;
    if (
      marker && Number.isFinite(Number(marker.sampleX)) &&
      Number.isFinite(min) && Number.isFinite(max) && max > min
    ) {
      return clamp((Number(marker.sampleX) - min) / (max - min));
    }
    return 0.5;
  }

  function captureStatus(capture) {
    const status = capture && typeof capture.status === "string"
      ? capture.status
      : "scanning";
    return ["scanning", "frozen", "reasoned", "locked", "confirm"].includes(
        status,
      )
      ? status
      : "scanning";
  }

  function captureReason(scenario, capture) {
    if (
      !gameModel || typeof gameModel.getEvidenceReason !== "function" ||
      !capture || !capture.selectedReasonId
    ) return null;
    try {
      return gameModel.getEvidenceReason(
        scenario.id,
        capture.selectedReasonId,
      ) || null;
    } catch (error) {
      return null;
    }
  }

  function captureFeedbackText(capture, fallback) {
    const feedback = capture && capture.feedback;
    if (typeof feedback === "string" && feedback.trim()) return feedback;
    if (feedback && typeof feedback === "object") {
      return feedback.message || feedback.label || feedback.text || fallback;
    }
    return fallback;
  }

  function captureFeedbackKind(capture) {
    const kind =
      capture && capture.feedback && typeof capture.feedback === "object"
        ? capture.feedback.kind
        : null;
    return ["info", "success", "warning", "error"].includes(kind)
      ? kind
      : "info";
  }

  function useReducedMotion() {
    const [reduced, setReduced] = useState(() =>
      window.matchMedia &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    );
    useEffect(() => {
      if (!window.matchMedia) return undefined;
      const media = window.matchMedia("(prefers-reduced-motion: reduce)");
      const update = () => setReduced(media.matches);
      if (media.addEventListener) media.addEventListener("change", update);
      else media.addListener(update);
      return () => {
        if (media.removeEventListener) {
          media.removeEventListener("change", update);
        } else media.removeListener(update);
      };
    }, []);
    return reduced;
  }

  function useLivePlayhead({ scenario, observation, active, override }) {
    const reducedMotion = useReducedMotion();
    const [playhead, setPlayhead] = useState(
      Number.isFinite(Number(override)) ? clamp(override) : 0,
    );

    useEffect(() => {
      if (Number.isFinite(Number(override))) {
        setPlayhead(clamp(Number(override)));
        return undefined;
      }
      const duration = Math.max(
        800,
        Number(observation && observation.durationMs) ||
          Number(scenario && scenario.observe && scenario.observe.durationMs) ||
          12000,
      );
      const suppliedStart = Number(observation && observation.startedAt);
      const startedAt = Number.isFinite(suppliedStart) && suppliedStart > 0
        ? suppliedStart
        : Date.now();
      const loop = scenario && scenario.kind === "rfi_scan";
      let frame = 0;

      const calculate = () => {
        const raw = Math.max(0, (Date.now() - startedAt) / duration);
        return loop ? raw % 1 : clamp(raw);
      };

      if (!active || reducedMotion) {
        setPlayhead(suppliedStart > 0 ? calculate() : reducedMotion ? 0.5 : 0);
        return undefined;
      }

      const tick = () => {
        const next = calculate();
        setPlayhead(next);
        if (loop || next < 1) frame = window.requestAnimationFrame(tick);
      };
      frame = window.requestAnimationFrame(tick);
      return () => window.cancelAnimationFrame(frame);
    }, [
      active,
      observation && observation.startedAt,
      observation && observation.durationMs,
      override,
      reducedMotion,
      scenario && scenario.id,
    ]);

    return playhead;
  }

  function ChartAxes({ scenario, ageBand = "6-8" }) {
    const ticks = xAxisTicks(scenario, ageBand);
    const yTicks = [0, 25, 50, 75, 100];
    return (
      <g className="v3gv-axes" aria-hidden="true">
        <rect
          className="v3gv-plot-bg"
          x={CHART.left}
          y={CHART.top}
          width={CHART.plotWidth}
          height={CHART.plotHeight}
        >
        </rect>
        {yTicks.map((value) => (
          <g key={value}>
            <line
              x1={CHART.left}
              x2={CHART.left + CHART.plotWidth}
              y1={chartY(value)}
              y2={chartY(value)}
            >
            </line>
            <text x={CHART.left - 18} y={chartY(value) + 7} textAnchor="end">
              {value}
            </text>
          </g>
        ))}
        {ticks.map((tick) => (
          <g key={`${tick.t}-${tick.label}`}>
            <line
              x1={chartX(tick.t)}
              x2={chartX(tick.t)}
              y1={CHART.top}
              y2={CHART.plotBottom}
            >
            </line>
            <text
              x={chartX(tick.t)}
              y={CHART.plotBottom + 34}
              textAnchor="middle"
            >
              {tick.label}
            </text>
          </g>
        ))}
        <text className="v3gv-axis-y-title" x={CHART.left} y={25}>
          {scenario && scenario.observe
            ? scenario.observe.axisLabel
            : "模拟信号强度"} · 0–100
        </text>
        <text
          className="v3gv-axis-x-title"
          x={CHART.left + CHART.plotWidth}
          y={CHART.height - 16}
          textAnchor="end"
        >
          {axisCaption(scenario)}
        </text>
      </g>
    );
  }

  function EvidenceStarOverlay(
    {
      scenario: scenarioProp,
      markers = [],
      points,
      revealProgress = 1,
      showLabels = true,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    if (!scenario) return null;
    const series = safeSeries(
      points || (scenario.observe && scenario.observe.points),
    );
    const visibleMarkers = (Array.isArray(markers) ? markers : [])
      .map((marker, index) => ({ marker, index, t: markerT(marker, scenario) }))
      .filter((entry) => entry.t <= clamp(revealProgress) + 0.0001);
    const frequency = scenario.observe && scenario.observe.xAxis &&
      scenario.observe.xAxis.kind === "frequency";

    return (
      <div
        className="v3gv-evidence-overlay"
        aria-label={`真实数据坐标上的 ${visibleMarkers.length} 个证据标记`}
      >
        <div className="v3gv-star-field">
          {visibleMarkers.map(({ marker, index, t }) => {
            const value = sampleSeries(series, t);
            const sampleX = Number.isFinite(Number(marker.sampleX))
              ? Number(marker.sampleX)
              : null;
            const positionLabel = frequency && sampleX !== null
              ? `${Math.round(sampleX)} ${marker.xUnit || "MHz"}`
              : marker.label || `证据 ${index + 1}`;
            const reasonLabel =
              typeof marker.reasonLabel === "string" && marker.reasonLabel
                ? marker.reasonLabel
                : null;
            const sourceBoundary = marker.sourceCertainty === "unconfirmed"
              ? "来源待查"
              : null;
            const label = [positionLabel, reasonLabel, sourceBoundary].filter(
              Boolean,
            ).join(" · ");
            return (
              <span
                className="v3gv-evidence-anchor"
                key={marker.id || `${t}-${index}`}
                style={{
                  "--v3gv-star-x": `${t * 100}%`,
                  "--v3gv-star-y": `${100 - value}%`,
                  "--v3gv-star-delay": `${index * 90}ms`,
                }}
                title={`${label}；模拟强度 ${Math.round(value)}`}
              >
                <i aria-hidden="true"></i>
                {showLabels ? <b>{label}</b> : null}
              </span>
            );
          })}
        </div>
      </div>
    );
  }

  function Playhead({ points, playhead, tone = "sun" }) {
    const value = sampleSeries(points, playhead);
    const x = chartX(playhead);
    const y = chartY(value);
    return (
      <g className={`v3gv-playhead v3gv-tone-${tone}`} aria-hidden="true">
        <line x1={x} x2={x} y1={CHART.top} y2={CHART.plotBottom}></line>
        <circle cx={x} cy={y} r="10"></circle>
        <circle className="v3gv-playhead-ring" cx={x} cy={y} r="20"></circle>
      </g>
    );
  }

  function CaptureLockFeedbackOverlay({ scenario, capture, points }) {
    const status = captureStatus(capture);
    if (!["locked", "confirm"].includes(status)) return null;

    const t = clamp(capture && capture.cursorT);
    const value = sampleSeries(points, t);
    const reason = captureReason(scenario, capture);
    const fallback = status === "locked"
      ? "证据星已吸附 · 请发现讲述员回应"
      : "小队已确认 · 信号来源仍待查";

    return (
      <div
        className={`v3gv-capture-overlay is-${status} has-feedback-${
          captureFeedbackKind(capture)
        }`}
        aria-live="polite"
        aria-label={captureFeedbackText(capture, fallback)}
      >
        {status === "locked"
          ? (
            <div
              className="v3gv-capture-target"
              style={{
                "--v3gv-capture-x": `${t * 100}%`,
                "--v3gv-capture-y": `${100 - value}%`,
              }}
            >
              <i className="v3gv-capture-star" aria-hidden="true"></i>
            </div>
          )
          : null}
        <div className="v3gv-capture-status">
          <strong>{captureFeedbackText(capture, fallback)}</strong>
          <small>{reason ? reason.label : "已保存形状理由"} · 来源待查</small>
        </div>
      </div>
    );
  }

  function SolarEnergyRiver(
    {
      scenario: scenarioProp,
      markers = [],
      playhead = 0,
      ageBand = "6-8",
      revealProgress = 1,
      capture,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    if (!scenario) return null;
    const points = safeSeries(scenario.observe.points);
    const trend = rollingMean(points, ageBand === "9-12" ? 4 : 5);
    const fillId = useVisualId("solar-energy-fill");
    const clipId = useVisualId("solar-reveal");
    const playedWidth = CHART.plotWidth * clamp(revealProgress);

    return (
      <div
        className="v3gv-chart-shell v3gv-solar-shell"
        data-visual="solar-energy-river"
      >
        <div className="v3gv-chart-callout v3gv-chart-callout-left">
          <strong>金色能量河</strong>
          <span>看整条河的高度，不只追最高浪花</span>
        </div>
        <svg
          className="v3gv-chart-svg"
          viewBox={`0 0 ${CHART.width} ${CHART.height}`}
          preserveAspectRatio="none"
          role="img"
          aria-label={`${scenario.observe.signalLabel}。真实固定模拟数据以金色面积和曲线表示，纵轴固定 0 到 100。`}
        >
          <defs>
            <linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="#ffe29a" stopOpacity="0.78"></stop>
              <stop offset="62%" stopColor="#ffc857" stopOpacity="0.26"></stop>
              <stop offset="100%" stopColor="#ffc857" stopOpacity="0.03"></stop>
            </linearGradient>
            <clipPath id={clipId}>
              <rect
                x={CHART.left}
                y={CHART.top}
                width={playedWidth}
                height={CHART.plotHeight}
              >
              </rect>
            </clipPath>
          </defs>
          <ChartAxes scenario={scenario} ageBand={ageBand} />
          <path className="v3gv-series-ghost" d={linePath(points)}></path>
          <g clipPath={`url(#${clipId})`}>
            <path
              className="v3gv-solar-area"
              d={areaPath(points)}
              fill={`url(#${fillId})`}
            >
            </path>
            <path className="v3gv-solar-trend" d={linePath(trend)}></path>
            <path
              className="v3gv-series-line v3gv-series-sun"
              d={linePath(points)}
            >
            </path>
          </g>
          <Playhead points={points} playhead={playhead} tone="sun" />
        </svg>
        <div className="v3gv-derived-note">
          粗金线是同一组样本的平滑趋势，不是额外测量
        </div>
        <EvidenceStarOverlay
          scenario={scenario}
          markers={markers}
          points={points}
          revealProgress={revealProgress}
        />
        <CaptureLockFeedbackOverlay
          scenario={scenario}
          capture={capture}
          points={points}
        />
      </div>
    );
  }

  function derivePassStages(scenario) {
    const points = safeSeries(scenario.observe.points);
    const background = safeSeries(
      scenario.compare && scenario.compare.background &&
        scenario.compare.background.points,
    );
    const threshold = average(background) + 8;
    const above = points.filter((point) => point.value >= threshold);
    const peak = points.reduce(
      (best, point) => point.value > best.value ? point : best,
      points[0] || { t: 0.5, value: 0 },
    );
    return {
      start: above.length ? above[0].t : 0.25,
      peak: peak.t,
      end: above.length ? above[above.length - 1].t : 0.75,
      threshold,
    };
  }

  function SatellitePassView(
    {
      scenario: scenarioProp,
      markers = [],
      playhead = 0,
      ageBand = "6-8",
      revealProgress = 1,
      capture,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    if (!scenario) return null;
    const points = safeSeries(scenario.observe.points);
    const stages = derivePassStages(scenario);
    const fillId = useVisualId("satellite-pass-fill");
    const clipId = useVisualId("satellite-reveal");
    const playedWidth = CHART.plotWidth * clamp(revealProgress);
    const status = captureStatus(capture);
    const revealStageNames = ageBand === "9-12" || markers.length > 0 ||
      status === "locked" || status === "confirm";

    return (
      <div
        className="v3gv-chart-shell v3gv-satellite-shell"
        data-visual="satellite-pass"
      >
        <div className="v3gv-orbit-rail" aria-hidden="true">
          <span className="v3gv-orbit-label">轨迹图标只表示回放进度</span>
          <i className="v3gv-orbit-line"></i>
          <img
            src={scenario.targetAsset}
            alt=""
            style={{ "--v3gv-orbit-progress": `${clamp(playhead) * 100}%` }}
          />
        </div>
        <svg
          className="v3gv-chart-svg"
          viewBox={`0 0 ${CHART.width} ${CHART.height}`}
          preserveAspectRatio="none"
          role="img"
          aria-label={`${scenario.observe.signalLabel}。出现、最强和离开位置由固定模拟包络的阈值及峰值计算。`}
        >
          <defs>
            <linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="#6ee8ef" stopOpacity="0.7"></stop>
              <stop offset="100%" stopColor="#29d8f2" stopOpacity="0.03"></stop>
            </linearGradient>
            <clipPath id={clipId}>
              <rect
                x={CHART.left}
                y={CHART.top}
                width={playedWidth}
                height={CHART.plotHeight}
              >
              </rect>
            </clipPath>
          </defs>
          <ChartAxes scenario={scenario} ageBand={ageBand} />
          {revealStageNames
            ? (
              <g className="v3gv-pass-zones" aria-hidden="true">
                <rect
                  x={chartX(stages.start)}
                  y={CHART.top}
                  width={Math.max(
                    8,
                    chartX(stages.peak) - chartX(stages.start),
                  )}
                  height={CHART.plotHeight}
                >
                </rect>
                <rect
                  className="peak"
                  x={chartX(stages.peak) - 25}
                  y={CHART.top}
                  width="50"
                  height={CHART.plotHeight}
                >
                </rect>
                <rect
                  x={chartX(stages.peak)}
                  y={CHART.top}
                  width={Math.max(8, chartX(stages.end) - chartX(stages.peak))}
                  height={CHART.plotHeight}
                >
                </rect>
                <text x={chartX(stages.start)} y={CHART.top + 30}>出现</text>
                <text
                  className="peak"
                  x={chartX(stages.peak)}
                  y={CHART.top + 30}
                  textAnchor="middle"
                >
                  最强
                </text>
                <text
                  x={chartX(stages.end)}
                  y={CHART.top + 30}
                  textAnchor="end"
                >
                  离开
                </text>
              </g>
            )
            : null}
          <line
            className="v3gv-threshold-line"
            x1={CHART.left}
            x2={CHART.left + CHART.plotWidth}
            y1={chartY(stages.threshold)}
            y2={chartY(stages.threshold)}
          >
          </line>
          <path className="v3gv-series-ghost" d={linePath(points)}></path>
          <g clipPath={`url(#${clipId})`}>
            <path
              className="v3gv-satellite-area"
              d={areaPath(points)}
              fill={`url(#${fillId})`}
            >
            </path>
            <path
              className="v3gv-series-line v3gv-series-satellite"
              d={linePath(points)}
            >
            </path>
          </g>
          <Playhead points={points} playhead={playhead} tone="signal" />
        </svg>
        <div className="v3gv-derived-note">
          三段位置来自这组模拟包络的背景阈值和峰值；强弱不能确认卫星身份
        </div>
        <EvidenceStarOverlay
          scenario={scenario}
          markers={markers}
          points={points}
          revealProgress={revealProgress}
        />
        <CaptureLockFeedbackOverlay
          scenario={scenario}
          capture={capture}
          points={points}
        />
      </div>
    );
  }

  function deriveRfiFeatures(scenario) {
    const quiet = safeSeries(
      scenario.compare && scenario.compare.background &&
        scenario.compare.background.points,
    );
    const active = safeSeries(
      scenario.compare && scenario.compare.target &&
        scenario.compare.target.points,
    );
    const changed = active.filter((point) =>
      point.value - sampleSeries(quiet, point.t) > 12
    );
    const band = changed.length
      ? { start: changed[0].t, end: changed[changed.length - 1].t }
      : { start: 0.735, end: 0.755 };
    const points = safeSeries(scenario.observe.points);
    const peaks = points.filter((point, index) => {
      if (index < 1 || index >= points.length - 1) return false;
      if (point.t >= band.start - 0.01 && point.t <= band.end + 0.01) {
        return false;
      }
      const localFloor = Math.max(
        points[index - 1].value,
        points[index + 1].value,
      );
      return point.value > 32 && point.value - localFloor > 2.5;
    }).sort((a, b) => b.value - a.value).slice(0, 2).sort((a, b) => a.t - b.t);
    return { band, peaks };
  }

  function RfiSpectrumScout(
    {
      scenario: scenarioProp,
      markers = [],
      playhead = 0,
      ageBand = "6-8",
      revealProgress = 1,
      capture,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    if (!scenario) return null;
    const points = safeSeries(scenario.observe.points);
    const features = deriveRfiFeatures(scenario);
    const fillId = useVisualId("rfi-spectrum-fill");
    const clipId = useVisualId("rfi-reveal");
    const playedWidth = CHART.plotWidth * clamp(revealProgress);
    const bandStart = chartX(features.band.start);
    const bandEnd = chartX(features.band.end);
    const beamX = chartX(playhead);
    const stripWidth = Math.max(
      2,
      CHART.plotWidth / Math.max(1, points.length - 1),
    );
    const status = captureStatus(capture);
    const revealInterpretation = ageBand === "9-12" || markers.length > 0 ||
      status === "locked" || status === "confirm";

    return (
      <div
        className={`v3gv-chart-shell v3gv-rfi-shell ${
          revealInterpretation ? "has-revealed-clues" : "is-clue-blind"
        }`}
        data-visual="rfi-spectrum-scout"
        data-capture-status={status}
      >
        <div
          className="v3gv-rfi-clues"
          aria-label={revealInterpretation
            ? "频谱侦探线索已揭示"
            : "频谱侦探线索等待发现"}
        >
          {revealInterpretation
            ? <span>宽亮带：一段频率同时升高</span>
            : <span>先找：哪里的形状不一样？</span>}
          {revealInterpretation
            ? <span>窄峰：来源仍待查</span>
            : <span>喊停后，用大圈把它圈住</span>}
          <strong>只看强弱 · 不听内容</strong>
        </div>
        <svg
          className="v3gv-chart-svg"
          viewBox={`0 0 ${CHART.width} ${CHART.height}`}
          preserveAspectRatio="none"
          role="img"
          aria-label="1000 到 2000 MHz 固定教学模拟频谱。亮度来自样本强度，不代表信号身份。"
        >
          <defs>
            <linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="#ffb08d" stopOpacity="0.72"></stop>
              <stop offset="100%" stopColor="#ff7c7c" stopOpacity="0.03"></stop>
            </linearGradient>
            <clipPath id={clipId}>
              <rect
                x={CHART.left}
                y={CHART.top}
                width={playedWidth}
                height={CHART.plotHeight}
              >
              </rect>
            </clipPath>
          </defs>
          <ChartAxes scenario={scenario} ageBand={ageBand} />
          <g
            className="v3gv-rfi-intensity-map"
            clipPath={`url(#${clipId})`}
            aria-hidden="true"
          >
            {points.map((point, index) => (
              <rect
                key={`${point.t}-${index}`}
                x={chartX(point.t) - stripWidth / 2}
                y={chartY(point.value)}
                width={stripWidth + 1}
                height={CHART.plotBottom - chartY(point.value)}
                style={{ opacity: 0.08 + (point.value / 100) * 0.42 }}
              >
              </rect>
            ))}
          </g>
          {revealInterpretation
            ? (
              <>
                <rect
                  className="v3gv-rfi-band"
                  x={bandStart}
                  y={CHART.top}
                  width={Math.max(8, bandEnd - bandStart)}
                  height={CHART.plotHeight}
                >
                </rect>
                <text
                  className="v3gv-rfi-band-label"
                  x={(bandStart + bandEnd) / 2}
                  y={CHART.top + 31}
                  textAnchor="middle"
                >
                  局部宽带增强
                </text>
                {features.peaks.map((peak, index) => (
                  <g
                    className="v3gv-rfi-peak"
                    key={`${peak.t}-${index}`}
                    aria-hidden="true"
                  >
                    <line
                      x1={chartX(peak.t)}
                      x2={chartX(peak.t)}
                      y1={chartY(peak.value) - 45}
                      y2={chartY(peak.value) - 8}
                    >
                    </line>
                    <circle cx={chartX(peak.t)} cy={chartY(peak.value)} r="8">
                    </circle>
                    <text
                      x={chartX(peak.t)}
                      y={Math.max(CHART.top + 66, chartY(peak.value) - 54)}
                      textAnchor="middle"
                    >
                      窄峰候选
                    </text>
                  </g>
                ))}
              </>
            )
            : null}
          <path className="v3gv-series-ghost" d={linePath(points)}></path>
          <g clipPath={`url(#${clipId})`}>
            <path
              className="v3gv-rfi-area"
              d={areaPath(points)}
              fill={`url(#${fillId})`}
            >
            </path>
            <path
              className="v3gv-series-line v3gv-series-rfi"
              d={linePath(points)}
            >
            </path>
          </g>
          <g className="v3gv-scan-beam" aria-hidden="true">
            <rect
              x={beamX - 16}
              y={CHART.top}
              width="32"
              height={CHART.plotHeight}
            >
            </rect>
            <line x1={beamX} x2={beamX} y1={CHART.top} y2={CHART.plotBottom}>
            </line>
          </g>
          <Playhead points={points} playhead={playhead} tone="rfi" />
        </svg>
        <div className="v3gv-derived-note">
          亮度与高度都映射固定模拟强度；频谱形状不能确认设备、协议或通信内容
        </div>
        <EvidenceStarOverlay
          scenario={scenario}
          markers={markers}
          points={points}
          revealProgress={revealProgress}
        />
        <CaptureLockFeedbackOverlay
          scenario={scenario}
          capture={capture}
          points={points}
        />
      </div>
    );
  }

  function MissionVisualCore(
    { scenario, markers, playhead, ageBand, revealProgress = 1, capture },
  ) {
    if (!scenario) return null;
    const props = {
      scenario,
      markers,
      playhead,
      ageBand,
      revealProgress,
      capture,
    };
    if (scenario.kind === "satellite_pass") {
      return <SatellitePassView {...props} />;
    }
    if (scenario.kind === "rfi_scan") return <RfiSpectrumScout {...props} />;
    return <SolarEnergyRiver {...props} />;
  }

  function missionCopy(scenario) {
    if (scenario.kind === "satellite_pass") {
      return {
        eyebrow: "卫星过境追踪",
        title: "抓住出现、最强、离开",
        hint: "曲线强弱是证据，不是卫星身份证",
      };
    }
    if (scenario.kind === "rfi_scan") {
      return {
        eyebrow: "L 波段频谱侦探",
        title: "让探照灯扫过 1000–2000 MHz",
        hint: "找形状和变化，来源始终待查",
      };
    }
    return {
      eyebrow: "太阳能量河",
      title: "看整体有没有抬升",
      hint: "不要只追一个最高点",
    };
  }

  function MissionDataStage(
    { scenario: scenarioProp, state = {}, ageBand = "6-8", active },
  ) {
    const scenario = resolveScenario(scenarioProp);
    if (!scenario) return <div className="v3gv-empty">缺少任务数据</div>;
    const observation = state && state.observation ? state.observation : {};
    const markers =
      state && state.responses && Array.isArray(state.responses.markers)
        ? state.responses.markers
        : [];
    const isActive = typeof active === "boolean"
      ? active
      : state.runState === "active";
    const visualMode = state && state.visualization
      ? state.visualization.mode
      : null;
    const effectiveAgeBand = visualMode === "detective"
      ? "9-12"
      : visualMode === "discovery"
      ? "6-8"
      : ageBand;
    const capture = state && state.capture && typeof state.capture === "object"
      ? state.capture
      : { status: "scanning", cursorT: 0 };
    const status = captureStatus(capture);
    const frozenCursor = status === "scanning" ? undefined : capture.cursorT;
    const playhead = useLivePlayhead({
      scenario,
      observation,
      active: isActive,
      override: frozenCursor,
    });
    const copy = missionCopy(scenario);
    const frequency = scenario.observe && scenario.observe.xAxis &&
      scenario.observe.xAxis.kind === "frequency";

    return (
      <section
        className="v3gv-mission-stage"
        data-kind={scenario.kind}
        data-screen-label={`游戏化数据·${
          scenario.shortLabel || scenario.targetName
        }`}
      >
        <header className="v3gv-stage-header">
          <div>
            <span>{copy.eyebrow}</span>
            <h2>{copy.title}</h2>
            <p>{copy.hint}</p>
          </div>
          <div className="v3gv-truth-badges" aria-label="数据边界">
            <span>固定教学模拟</span>
            <span>{frequency ? "1000–2000 MHz" : "同一 0–100 刻度"}</span>
            <strong>{markers.length} 颗证据星</strong>
          </div>
        </header>
        <MissionVisualCore
          scenario={scenario}
          markers={markers}
          playhead={playhead}
          ageBand={effectiveAgeBand}
          revealProgress={1}
          capture={capture}
        />
      </section>
    );
  }

  function CompareLens(
    {
      scenario: scenarioProp,
      ageBand = "6-8",
      initialMode = "lens",
      onLensChange,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    const [mode, setMode] = useState(
      initialMode === "overlay" ? "overlay" : "lens",
    );
    const [lens, setLens] = useState(52);
    const draggingRef = useRef(false);
    const clipId = useVisualId("compare-lens-clip");
    if (!scenario || !scenario.compare) {
      return <div className="v3gv-empty">缺少比较数据</div>;
    }

    const background = safeSeries(scenario.compare.background.points);
    const target = safeSeries(scenario.compare.target.points);
    const lensT = clamp(lens / 100);
    const lensX = chartX(lensT);
    const backgroundValue = sampleSeries(background, lensT);
    const targetValue = sampleSeries(target, lensT);
    const targetTone = scenario.kind === "rfi_scan"
      ? "rfi"
      : scenario.kind === "satellite_pass"
      ? "signal"
      : "sun";

    const updateLens = (next) => {
      const value = clamp(next, 0, 100);
      setLens(value);
      if (typeof onLensChange === "function") onLensChange(value / 100);
    };

    const pointerToLens = (event) => {
      const rect = event.currentTarget.getBoundingClientRect();
      const plotLeft = rect.left + rect.width * (CHART.left / CHART.width);
      const plotRight = rect.right - rect.width * (CHART.right / CHART.width);
      updateLens(
        ((event.clientX - plotLeft) / Math.max(1, plotRight - plotLeft)) * 100,
      );
    };

    const onPointerDown = (event) => {
      if (mode !== "lens") return;
      draggingRef.current = true;
      if (event.currentTarget.setPointerCapture) {
        event.currentTarget.setPointerCapture(event.pointerId);
      }
      pointerToLens(event);
    };

    const onPointerMove = (event) => {
      if (mode === "lens" && draggingRef.current) pointerToLens(event);
    };

    const onPointerUp = () => {
      draggingRef.current = false;
    };

    return (
      <section
        className="v3gv-compare-lens"
        data-kind={scenario.kind}
        data-screen-label={`同刻度比较·${
          scenario.shortLabel || scenario.targetName
        }`}
      >
        <header className="v3gv-compare-header">
          <div>
            <span>同一坐标 · 两组固定模拟</span>
            <h2>
              {mode === "lens" ? "拖动透镜，逐段找不同" : "叠在一起，看数值差"}
            </h2>
          </div>
          <div className="v3gv-mode-switch" role="group" aria-label="比较方式">
            <button
              type="button"
              className={mode === "lens" ? "is-active" : ""}
              onClick={() => setMode("lens")}
            >
              滑动透镜
            </button>
            <button
              type="button"
              className={mode === "overlay" ? "is-active" : ""}
              onClick={() => setMode("overlay")}
            >
              叠加比较
            </button>
          </div>
        </header>
        <div
          className={`v3gv-lens-stage ${mode === "lens" ? "is-draggable" : ""}`}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
        >
          <div className="v3gv-compare-key" aria-hidden="true">
            <span className="background">
              {scenario.compare.background.label}
            </span>
            <span className={targetTone}>{scenario.compare.target.label}</span>
          </div>
          <svg
            className="v3gv-chart-svg"
            viewBox={`0 0 ${CHART.width} ${CHART.height}`}
            preserveAspectRatio="none"
            role="img"
            aria-label={`${scenario.compare.background.label}和${scenario.compare.target.label}使用同一横轴和 0 到 100 纵轴比较。`}
          >
            <defs>
              <clipPath id={clipId}>
                <rect
                  x={CHART.left}
                  y={CHART.top}
                  width={CHART.plotWidth * lensT}
                  height={CHART.plotHeight}
                >
                </rect>
              </clipPath>
            </defs>
            <ChartAxes scenario={scenario} ageBand={ageBand} />
            {mode === "overlay"
              ? (
                <path
                  className={`v3gv-difference-fill v3gv-tone-${targetTone}`}
                  d={differencePath(background, target)}
                >
                </path>
              )
              : null}
            <path
              className="v3gv-series-line v3gv-series-background"
              d={linePath(background)}
            >
            </path>
            <g clipPath={mode === "lens" ? `url(#${clipId})` : undefined}>
              {mode === "lens"
                ? (
                  <rect
                    className={`v3gv-lens-wash v3gv-tone-${targetTone}`}
                    x={CHART.left}
                    y={CHART.top}
                    width={CHART.plotWidth * lensT}
                    height={CHART.plotHeight}
                  >
                  </rect>
                )
                : null}
              <path
                className={`v3gv-series-line v3gv-series-${targetTone}`}
                d={linePath(target)}
              >
              </path>
            </g>
            {mode === "lens"
              ? (
                <g className="v3gv-lens-handle" aria-hidden="true">
                  <line
                    x1={lensX}
                    x2={lensX}
                    y1={CHART.top}
                    y2={CHART.plotBottom}
                  >
                  </line>
                  <circle
                    cx={lensX}
                    cy={CHART.top + CHART.plotHeight / 2}
                    r="28"
                  >
                  </circle>
                  <path
                    d={`M${lensX - 10},${
                      CHART.top + CHART.plotHeight / 2
                    } h20 M${lensX - 10},${
                      CHART.top + CHART.plotHeight / 2
                    } l7,-7 M${lensX - 10},${
                      CHART.top + CHART.plotHeight / 2
                    } l7,7 M${lensX + 10},${
                      CHART.top + CHART.plotHeight / 2
                    } l-7,-7 M${lensX + 10},${
                      CHART.top + CHART.plotHeight / 2
                    } l-7,7`}
                  >
                  </path>
                </g>
              )
              : null}
          </svg>
        </div>
        <div className="v3gv-lens-controls">
          <div className="v3gv-live-values" aria-live="polite">
            <span>
              <i className="background"></i>
              {scenario.compare.background.label}
              <strong>{Math.round(backgroundValue)}</strong>
            </span>
            <span>
              <i className={targetTone}></i>
              {scenario.compare.target.label}
              <strong>{Math.round(targetValue)}</strong>
            </span>
          </div>
          <label className={mode === "lens" ? "" : "is-hidden"}>
            <span>左右拖动</span>
            <input
              type="range"
              min="0"
              max="100"
              step="1"
              value={Math.round(lens)}
              onChange={(event) => updateLens(Number(event.target.value))}
              aria-label="比较透镜位置"
            />
          </label>
        </div>
        <p className="v3gv-boundary-note">
          亮色区域只表示两组固定模拟的数值差；它不证明信号来源或目标身份。
        </p>
      </section>
    );
  }

  function EvidenceReplay(
    {
      scenario: scenarioProp,
      markers = [],
      ageBand = "6-8",
      durationMs = 8000,
      autoPlay = true,
      compact = false,
    },
  ) {
    const scenario = resolveScenario(scenarioProp);
    const reducedMotion = useReducedMotion();
    const [progress, setProgress] = useState(reducedMotion ? 1 : 0);
    const [playing, setPlaying] = useState(Boolean(autoPlay) && !reducedMotion);
    const progressRef = useRef(progress);
    const sortedMarkers = useMemo(
      () =>
        (Array.isArray(markers) ? markers : []).slice().sort((a, b) =>
          markerT(a, scenario) - markerT(b, scenario)
        ),
      [markers, scenario && scenario.id],
    );

    useEffect(() => {
      progressRef.current = progress;
    }, [progress]);

    useEffect(() => {
      setProgress(reducedMotion ? 1 : 0);
      setPlaying(Boolean(autoPlay) && !reducedMotion);
    }, [scenario && scenario.id, reducedMotion, autoPlay]);

    useEffect(() => {
      if (!playing || reducedMotion) return undefined;
      let frame = 0;
      const from = progressRef.current >= 1 ? 0 : progressRef.current;
      if (from !== progressRef.current) {
        progressRef.current = from;
        setProgress(from);
      }
      const startedAt = performance.now();
      const remainingDuration = Math.max(1200, Number(durationMs) || 8000) *
        (1 - from);
      const tick = (time) => {
        const next = clamp(
          from + ((time - startedAt) / remainingDuration) * (1 - from),
        );
        progressRef.current = next;
        setProgress(next);
        if (next >= 1) setPlaying(false);
        else frame = window.requestAnimationFrame(tick);
      };
      frame = window.requestAnimationFrame(tick);
      return () => window.cancelAnimationFrame(frame);
    }, [playing, reducedMotion, durationMs, scenario && scenario.id]);

    if (!scenario) return <div className="v3gv-empty">缺少回放数据</div>;

    const visibleCount =
      sortedMarkers.filter((marker) =>
        markerT(marker, scenario) <= progress + 0.0001
      ).length;
    const frequency = scenario.observe && scenario.observe.xAxis &&
      scenario.observe.xAxis.kind === "frequency";
    const changeProgress = (next) => {
      const value = clamp(next, 0, 100);
      progressRef.current = value;
      setProgress(value);
      setPlaying(false);
    };

    return (
      <section
        className={`v3gv-replay ${compact ? "is-compact" : ""}`}
        data-kind={scenario.kind}
        data-screen-label={`证据回放·${
          scenario.shortLabel || scenario.targetName
        }`}
      >
        <header className="v3gv-replay-header">
          <div>
            <span>小队证据回放</span>
            <h2>看看我们在哪里留下了星星</h2>
          </div>
          <strong>{visibleCount} / {sortedMarkers.length} 颗证据星</strong>
        </header>
        <MissionVisualCore
          scenario={scenario}
          markers={sortedMarkers}
          playhead={progress}
          ageBand={ageBand}
          revealProgress={progress}
        />
        <div className="v3gv-replay-timeline">
          <div className="v3gv-replay-ticks" aria-hidden="true">
            {sortedMarkers.map((marker, index) => {
              const t = markerT(marker, scenario);
              return (
                <i
                  key={marker.id || index}
                  className={t <= progress ? "is-seen" : ""}
                  style={{ left: `${t * 100}%` }}
                >
                </i>
              );
            })}
          </div>
          <input
            type="range"
            min="0"
            max="1"
            step="0.002"
            value={progress}
            onChange={(event) => changeProgress(Number(event.target.value))}
            aria-label="拖动数据回放进度"
          />
        </div>
        <div className="v3gv-replay-controls">
          <button
            type="button"
            className="v3gv-replay-primary"
            onClick={() => setPlaying((value) => !value)}
            disabled={reducedMotion}
          >
            {reducedMotion
              ? "已显示全部"
              : playing
              ? "暂停回放"
              : progress >= 1
              ? "再看一次"
              : "继续回放"}
          </button>
          <button type="button" onClick={() => changeProgress(0)}>
            回到开始
          </button>
          <span>
            {frequency
              ? `${Math.round(1000 + progress * 1000)} MHz`
              : `进度 ${Math.round(progress * 100)}%`}
          </span>
        </div>
        <p className="v3gv-boundary-note">
          回放复现同一组固定教学模拟和小队保存的坐标，没有生成新的观测数据。
        </p>
      </section>
    );
  }

  Object.assign(window, {
    V3GameVisuals: {
      MissionDataStage,
      CompareLens,
      EvidenceReplay,
      EvidenceStarOverlay,
      SolarEnergyRiver,
      SatellitePassView,
      RfiSpectrumScout,
    },
  });
})();
