/**
 * 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 } }`.
 * - <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.
 *
 * 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 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 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 label = frequency && sampleX !== null
              ? `${Math.round(sampleX)} ${marker.xUnit || "MHz"}`
              : marker.label || `证据 ${index + 1}`;
            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 SolarEnergyRiver({ scenario: scenarioProp, markers = [], playhead = 0, ageBand = "6-8", revealProgress = 1 }) {
    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} />
      </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 }) {
    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);

    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} />
          <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>
          <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} />
      </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 }) {
    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));

    return (
      <div className="v3gv-chart-shell v3gv-rfi-shell" data-visual="rfi-spectrum-scout">
        <div className="v3gv-rfi-clues" aria-label="频谱侦探线索">
          <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>
          <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>
          ))}
          <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} />
      </div>
    );
  }

  function MissionVisualCore({ scenario, markers, playhead, ageBand, revealProgress = 1 }) {
    if (!scenario) return null;
    const props = { scenario, markers, playhead, ageBand, revealProgress };
    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 playhead = useLivePlayhead({ scenario, observation, active: isActive });
    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} />
      </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,
    },
  });
})();
