// force-compare.jsx  v2  —  测力台 · 对比页（跨类型 · 跨运动员 · 曲线/指标叠加视图）
// FORCE-WS-5a (2026-07-12): ForceCompareView + its EXCLUSIVE helpers (CompareLoopChart,
//   COMPARE_PALETTE) extracted VERBATIM from cmj.jsx. USER-AUTHORIZED structural move —
//   a PURE relocation at extraction time: zero change to the compare logic / ForceCore
//   algorithms / metric or curve math. FORCE-UI (2026-07-22) later added sanctioned,
//   display-only axis visibility and card-layout changes; the durable gate records that
//   reviewed canonical block. window.ForceCompareView +
//   window.CMJCompareView (backward-compat alias) now live here.
// Load order: AFTER cmj.jsx (so window.__FORCE_TEST_INTERNALS__.cmj + the FORCE core
//   are published) and after sj.jsx / imtp.jsx; BEFORE the consumers — force-workspace.jsx
//   对比 mode routes view 'force-compare' → app-view-renderers.jsx <ForceCompareView>.
// Shared cmj internals (ALL_SUMMARY_METRICS / DEFAULT_METRIC_KEYS / SECTION_COLORS /
//   KPOINT_DESCS) are consumed via the existing __FORCE_TEST_INTERNALS__.cmj channel,
//   aliased at IIFE top so the relocated code keeps its original bare-identifier
//   references. SJ/IMTP metric defs + FORCE_TEST_CONFIGS are read from window at render
//   time (unchanged). Direction: FORCE-WS-D1 §5 + master-plan P1④/F6.

(function () {
  const { useState, useEffect } = React;
  // cmj.jsx internals — published by cmj.jsx's IIFE, which loads first. The moved
  // ForceCompareView / CompareLoopChart reference these four as bare identifiers;
  // aliasing them here preserves the byte-identical relocation (no logic change).
  const __cmj = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
  const { ALL_SUMMARY_METRICS, DEFAULT_METRIC_KEYS, SECTION_COLORS, KPOINT_DESCS } = __cmj;

  // ── 9. CMJ COMPARE VIEW ───────────────────────────────────────────────────
  // Cross-athlete comparison: metrics table + normalised force-time overlay + type distribution.

  const COMPARE_PALETTE = [
    '#3b82f6', '#34d399', '#f59e0b', '#f472b6',
    '#a78bfa', '#fb923c', '#22d3ee', '#f87171',
    '#84cc16', '#e879f9',
  ];

  function ensureForceCompareStyle() {
    if (typeof document === 'undefined' || document.getElementById('force-compare-style')) return;
    const style = document.createElement('style');
    style.id = 'force-compare-style';
    style.textContent = `
      .fc-page { flex: 1; min-width: 0; width: min(100%,1480px); margin: 0 auto; padding: 18px 24px 52px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
      .fc-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 0 2px; }
      .fc-head > strong { color: var(--text); font-size: 16px; font-weight: 680; letter-spacing: -.02em; text-wrap: balance; }
      .fc-setup-card { border: 1px solid var(--border); border-radius: 12px; padding: 13px 16px 15px; background: var(--panel); box-shadow: 0 8px 24px rgba(31,41,55,.04); }
      .fc-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; padding: 3px 4px 1px; }
      .fc-chart-layout { display: grid; grid-template-columns: minmax(0,1.4fr) repeat(2,minmax(240px,.72fr)); gap: 12px; align-items: start; }
      .fc-loop-grid { display: contents; }
      .fc-chart-card { min-width: 0; align-self: stretch; border: 1px solid var(--border); border-radius: 11px; padding: 12px 14px 16px; background: var(--panel); box-shadow: 0 8px 26px rgba(31,41,55,.045); overflow: visible; }
      .fc-chart-card-normalized { padding: 14px 16px 16px; }
      .fc-chart-card svg { display: block; width: 100%; height: auto; max-height: none; overflow: visible !important; margin-bottom: 8px; }
      .fc-metrics-card { border: 1px solid var(--border); border-radius: 11px; overflow: hidden; background: var(--panel); box-shadow: 0 8px 26px rgba(31,41,55,.04); }
      @media (max-width: 1180px) { .fc-chart-layout { grid-template-columns: repeat(2,minmax(0,1fr)); } .fc-chart-card-normalized { grid-column: 1 / -1; } }
      @media (max-width: 760px) { .fc-page { padding: 14px 12px 44px; } .fc-chart-layout { grid-template-columns: 1fr; } .fc-chart-card-normalized { grid-column: 1; } }
    `;
    document.head.appendChild(style);
  }

  // ── Cross-athlete Loop chart ──────────────────────────────────────────────
  // Mirrors the Analysis `LoopChart` layout exactly (same W/H/margins,
  // same axis style, same key-point markers), but builds its series from
  // the stored downsampled curves rather than raw force arrays.
  function CompareLoopChart({ mode, entries }) {
    const [tooltip, setTooltip] = useState(null);
    const isFD = mode === 'fd';
    const W = 390, H = 370;
    const ML = 52, MR = 14, MT = 28, MB = 46;
    const PW = W - ML - MR, PH = H - MT - MB;

    // ── Build series from stored curves ─────────────────────────────────────
    const series = entries
      .filter(e => e.trial?.curve?.f?.length && e.trial?.curve?.[isFD ? 'd' : 'v']?.length)
      .map((e, ci) => {
        const c    = e.trial.curve;
        const kp   = e.trial.keyPts || null;
        const xArr = isFD ? c.d : c.v;
        const yPct = c.f.map(f => f * 100);  // % BW
        const n    = c.t.length;

        const pts = [];
        for (let i = 0; i < n; i++) pts.push({ i, x: xArr[i], y: yPct[i] });

        // Map normalised-time key-point fractions onto this curve's index space
        const nearestIdx = (target) => {
          let bi = 0, bd = Infinity;
          for (let i = 0; i < n; i++) {
            const d = Math.abs(c.t[i] - target);
            if (d < bd) { bd = d; bi = i; }
          }
          return bi;
        };
        const kpts = kp ? ['a','b','c','d','e','f','g'].map(lab => {
          const t = kp[lab];
          if (t == null) return null;
          const i = nearestIdx(t);
          return { label: lab, x: xArr[i], y: yPct[i] };
        }).filter(Boolean) : [];

        // Shoelace loop area (closing the loop from last point back to first)
        let area = 0;
        for (let k = 0; k < n - 1; k++) area += pts[k].x * pts[k + 1].y - pts[k + 1].x * pts[k].y;
        area += pts[n - 1].x * pts[0].y - pts[0].x * pts[n - 1].y;
        // y is in %BW, x in m (FD) or m/s (FV); divide by 100 to convert %→×BW, then
        // by 1 (we don't know body weight in this context) → reported in unitless form
        const loopArea     = Math.abs(area) * 0.5 / 100;  // %BW·m or %BW·(m/s)
        const loopAreaUnit = isFD ? '·%BW·m' : '·%BW·(m/s)';

        return {
          ci,
          color: { line: e.color, marker: e.color },
          pts, kpts, loopArea, loopAreaUnit,
          label: `${e.athlete.name} T${e.trial.index}`,
        };
      });

    if (series.length === 0) return (
      <div style={{ display:'flex', alignItems:'center', justifyContent:'center',
        height: 240, color:'var(--muted)', fontSize: 12 }}>
        No curves to plot
      </div>
    );

    // ── Global axis ranges across all series (same as Analysis LoopChart) ───
    let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
    for (const s of series) for (const p of s.pts) {
      if (p.x < xMin) xMin = p.x; if (p.x > xMax) xMax = p.x;
      if (p.y < yMin) yMin = p.y; if (p.y > yMax) yMax = p.y;
    }
    const xPad = (xMax - xMin) * 0.12 || 0.05;
    const yPad = (yMax - yMin) * 0.12 || 15;
    xMin -= xPad; xMax += xPad;
    yMin = Math.max(0, yMin - yPad); yMax += yPad;

    const xS = x => ML + (x - xMin) / (xMax - xMin) * PW;
    const yS = y => MT + PH * (1 - (y - yMin) / (yMax - yMin));

    // Path builder for the whole curve (no phase-coloring in compare view)
    const fullPath = (pts) =>
      pts.map((p, i) => (i === 0 ? 'M' : 'L') + xS(p.x).toFixed(1) + ',' + yS(p.y).toFixed(1)).join('');

    // Direction arrow at ~frac of the path (same logic as Analysis LoopChart)
    const arrow = (pts, frac) => {
      if (pts.length < 4) return null;
      const mi = Math.max(1, Math.min(pts.length - 2, Math.floor(pts.length * frac)));
      const p1 = pts[mi - 1], p2 = pts[mi + 1];
      const dx = xS(p2.x) - xS(p1.x), dy = yS(p2.y) - yS(p1.y);
      const len = Math.sqrt(dx * dx + dy * dy);
      if (len < 2) return null;
      const ux = dx / len, uy = dy / len;
      const cx = xS(pts[mi].x), cy = yS(pts[mi].y);
      const sz = 6;
      const tx = cx + sz * ux, ty = cy + sz * uy;
      const l1x = cx + sz * (-ux * 0.5 + uy * 0.7), l1y = cy + sz * (-uy * 0.5 - ux * 0.7);
      const l2x = cx + sz * (-ux * 0.5 - uy * 0.7), l2y = cy + sz * (-uy * 0.5 + ux * 0.7);
      return `M${tx.toFixed(1)},${ty.toFixed(1)} L${l1x.toFixed(1)},${l1y.toFixed(1)} L${l2x.toFixed(1)},${l2y.toFixed(1)} Z`;
    };

    const showTip = (label, cx, cy) => {
      const desc = KPOINT_DESCS[label] || label;
      const tw = desc.length * 6.0 + 14;
      const tx = Math.min(Math.max(cx, ML + tw / 2 + 4), ML + PW - tw / 2 - 4);
      setTooltip({ desc, x: tx, y: cy > MT + PH * 0.5 ? cy - 14 : cy + 26, tw });
    };

    // Axis ticks (mirror Analysis LoopChart)
    const xRange  = xMax - xMin;
    const rawXStep = xRange / 5;
    const xStep   = isFD ? +(rawXStep.toFixed(2)) : +(rawXStep.toFixed(1));
    const xTicks  = [];
    for (let x = Math.ceil(xMin / xStep) * xStep; x <= xMax + xStep * 0.01; x += xStep)
      xTicks.push(+x.toFixed(isFD ? 2 : 1));

    const rawYStep = (yMax - yMin) / 5;
    const yStep    = Math.ceil(rawYStep / 25) * 25 || 25;
    const yTicks   = [];
    for (let y = Math.ceil(yMin / yStep) * yStep; y <= yMax + 1; y += yStep) yTicks.push(y);

    const title  = isFD ? 'Force–Displacement Loop' : 'Force–Velocity Loop';
    const xLabel = isFD ? 'Displacement (m)' : 'Velocity (m/s)';

    const yBW100 = yS(100);
    const xV0    = !isFD ? xS(0) : null;

    return (
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width:'100%', height:'auto', display:'block', overflow:'visible' }}
        onMouseLeave={() => setTooltip(null)}>
        <rect width={W} height={H} fill="var(--panel)" rx="6" />

        <text x={ML + PW / 2} y={16} textAnchor="middle" fontSize="10.5" fontWeight="600"
          fill="var(--text-2)">{title}</text>

        {/* 100% BW reference */}
        {yBW100 >= MT && yBW100 <= MT + PH && (
          <>
            <line x1={ML} y1={yBW100.toFixed(1)} x2={ML + PW} y2={yBW100.toFixed(1)}
              stroke="rgba(15,23,42,.22)" strokeWidth="1" strokeDasharray="5 3" />
            <text x={ML + 3} y={+yBW100.toFixed(1) - 4} fontSize="7.5" fill="rgba(255,255,255,.32)">100% BW</text>
          </>
        )}

        {/* Velocity = 0 reference for FV loop */}
        {!isFD && xV0 != null && (
          <line x1={xV0.toFixed(1)} y1={MT} x2={xV0.toFixed(1)} y2={MT + PH}
            stroke="rgba(15,23,42,.18)" strokeWidth="1" strokeDasharray="3 3" />
        )}

        {/* Per-series: curve, arrow, key-point markers */}
        {series.map(s => {
          const { pts, kpts, ci, color } = s;
          const arrowFrac = 0.35 + ci * 0.12;
          const arr = arrow(pts, arrowFrac);
          return (
            <g key={ci}>
              <path d={fullPath(pts)} fill="none" stroke={color.line} strokeWidth="1.7" strokeOpacity=".9" />
              {arr && <path d={arr} fill={color.line} fillOpacity=".8" stroke="none" />}
              {kpts.map(({ label, x, y }) => {
                const cx = xS(x), cy = yS(y);
                const above = cy > MT + PH * 0.5;
                return (
                  <g key={label} style={{ cursor:'default' }}
                    onMouseEnter={() => showTip(label, cx, cy)}
                    onMouseLeave={() => setTooltip(null)}>
                    <circle cx={cx.toFixed(1)} cy={cy.toFixed(1)} r="5"
                      fill="var(--bg)" stroke={color.line} strokeWidth="2" />
                    <text x={cx.toFixed(1)} y={(cy + (above ? -11 : 14)).toFixed(1)}
                      textAnchor="middle" fontSize="12" fontWeight="700" fontStyle="italic"
                      fill={color.line}
                      stroke="var(--bg)" strokeWidth="3.5" paintOrder="stroke" strokeLinejoin="round">{label}</text>
                  </g>
                );
              })}
            </g>
          );
        })}

        {tooltip && (
          <g style={{ pointerEvents:'none' }}>
            <rect x={(tooltip.x - tooltip.tw / 2 - 2).toFixed(1)} y={(tooltip.y - 16).toFixed(1)}
              width={(tooltip.tw + 8).toFixed(1)} height="22" rx="3"
              fill="var(--ink, #14120f)" stroke="rgba(0,0,0,.18)" strokeWidth="0.8" />
            <text x={tooltip.x.toFixed(1)} y={(tooltip.y - 3).toFixed(1)}
              textAnchor="middle" fontSize="11" fontWeight="500" fill="#fafafa">{tooltip.desc}</text>
          </g>
        )}

        {/* Bottom legend — one entry per athlete */}
        <g transform={`translate(${ML}, ${MT + PH + 20})`}>
          {series.map((s, i) => (
            <g key={s.ci} transform={`translate(${i * 110}, 0)`}>
              <line x1="0" y1="5" x2="12" y2="5" stroke={s.color.line} strokeWidth="2" />
              <text x="15" y="9" fontSize="7.5" fill="var(--muted)">{s.label}</text>
            </g>
          ))}
        </g>

        {/* Left Y-axis — Force %BW */}
        <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
        {yTicks.map(y => (
          <g key={y}>
            <line x1={ML - 4} y1={yS(y).toFixed(1)} x2={ML} y2={yS(y).toFixed(1)} stroke="rgba(15,23,42,.22)" />
            <text x={ML - 6} y={+yS(y).toFixed(1) + 3} textAnchor="end" fontSize="10.5" fill="var(--muted)">{y}%</text>
          </g>
        ))}
        <text x="10" y={MT + PH / 2} textAnchor="middle" fontSize="10.5" fill="var(--muted)"
          transform={`rotate(-90,10,${MT + PH / 2})`}>Force (%BW)</text>

        {/* Bottom X-axis */}
        <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
        {xTicks.map(x => (
          <g key={x}>
            <line x1={xS(x).toFixed(1)} y1={MT + PH} x2={xS(x).toFixed(1)} y2={MT + PH + 4} stroke="rgba(15,23,42,.22)" />
            <text x={xS(x).toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="10.5" fill="var(--muted)">{x}</text>
          </g>
        ))}
        <text x={ML + PW / 2} y={H - 4} textAnchor="middle" fontSize="10.5" fill="var(--muted)">{xLabel}</text>
      </svg>
    );
  }

  function ForceCompareView({ athletes, cmjStore, sjStore = {}, imtpStore = {}, onDeleteSession = {}, onNavigate, initialTestType = 'cmj' }) {
    useEffect(() => { ensureForceCompareStyle(); }, []);
    const [testType, setTestType] = useState(initialTestType || 'cmj');
    useEffect(() => {
      if (initialTestType && initialTestType !== testType) setTestType(initialTestType);
    }, [initialTestType]);

    const registry = window.FORCE_TEST_CONFIGS || {};
    // Per-test-type config — add new entries here to support future tests
    const TEST_CONFIGS = {
      cmj: {
        label: 'CMJ', store: cmjStore, metricDefs: ALL_SUMMARY_METRICS,
        defaultKeys: registry.cmj?.defaultCompareKeys || DEFAULT_METRIC_KEYS,
        sessionLabel: registry.cmj?.sessionLabel || (s => `${s.date} · ${s.jumpCount ?? s.trials?.length ?? '?'} trials · JH ${s.best?.jumpHeight?.toFixed(1) ?? '?'} cm`),
        emptyHint: registry.cmj?.emptyHint || '请先在力板测试页上传 CMJ 数据',
      },
      sj: {
        label: 'SJ', store: sjStore, metricDefs: (window.SJ_SUMMARY_METRICS || []).map(m => ({ ...m, section: '跳跃指标' })),
        defaultKeys: registry.sj?.defaultCompareKeys || ['jumpHeight', 'takeoffVelocity', 'propulsiveTime', 'peakPropForce', 'relPeakPower'],
        sessionLabel: registry.sj?.sessionLabel || (s => `${s.date} · ${s.jumpCount ?? s.trials?.length ?? '?'} jumps · JH ${s.best?.jumpHeight?.toFixed(1) ?? '?'} cm`),
        emptyHint: registry.sj?.emptyHint || '请先在力板测试页上传 SJ 数据',
      },
      imtp: {
        label: 'IMTP', store: imtpStore, metricDefs: (window.IMTP_SUMMARY_METRICS || []).map(m => ({ ...m, section: '力量指标' })),
        defaultKeys: registry.imtp?.defaultCompareKeys || ['peakForce', 'relPeakForce', 'timeToPeak', 'peakRFD', 'nimp200'],
        sessionLabel: registry.imtp?.sessionLabel || (s => `${s.date} · ${s.trialCount ?? s.trials?.length ?? '?'} trials · F_peak ${s.best?.peakForce?.toFixed(0) ?? '?'} N`),
        emptyHint: registry.imtp?.emptyHint || '请先在力板测试页上传 IMTP 数据',
      },
    };
    const cfg = TEST_CONFIGS[testType] || TEST_CONFIGS.cmj;
    const activeStore = cfg.store;
    const activeMetrics = cfg.metricDefs;
    const activeSections = [...new Set(activeMetrics.map(m => m.section))];

    const athletesWithData = athletes.filter(a => (activeStore[a.id] || []).length > 0);
    // picks: 可重复的对比条目 — 每条 = 运动员 + 场次 + 多选 Trial(自由混加:同人多场次 / 跨运动员)
    const [picks, setPicks] = useState(() => athletesWithData.slice(0, 4).map(a => ({ athleteId: a.id, sIdx: 0, tIdxs: [0] })));
    const [compareMetricKeys, setCompareMetricKeys] = useState(DEFAULT_METRIC_KEYS);
    const [pickerOpen, setPickerOpen] = useState(false);

    // Reset selections when switching test type
    useEffect(() => {
      const withData = athletes.filter(a => (cfg.store[a.id] || []).length > 0);
      setPicks(withData.slice(0, 4).map(a => ({ athleteId: a.id, sIdx: 0, tIdxs: [0] })));
      setCompareMetricKeys(cfg.defaultKeys);
      setPickerOpen(false);
    }, [testType]);

    const addPick = (athleteId) => setPicks(prev => [...prev, { athleteId, sIdx: 0, tIdxs: [0] }].slice(0, 12));
    const removePick = (i) => setPicks(prev => prev.filter((_, x) => x !== i));
    const setPickSession = (i, sIdx) => setPicks(prev => prev.map((p, x) => x === i ? { ...p, sIdx, tIdxs: [0] } : p));
    const togglePickTrial = (i, tIdx) => setPicks(prev => prev.map((p, x) => {
      if (x !== i) return p;
      const has = p.tIdxs.includes(tIdx);
      const next = has ? p.tIdxs.filter(t => t !== tIdx) : [...p.tIdxs, tIdx].sort((a, b) => a - b);
      return { ...p, tIdxs: next.length ? next : [tIdx] };
    }));

    // Comparability rule (GPT audit P1.2) — a rate-'limited' / invalid session is VISIBLE in the
    // picker but must NOT enter the comparison overlay/table. Single rule via ForceSessionSource.
    const _comparability = (s) => (typeof window !== 'undefined' && window.ForceSessionSource && window.ForceSessionSource.sessionComparability)
      ? window.ForceSessionSource.sessionComparability(s) : { comparable: true, reason: null };
    // 展开成"每条曲线一项":每个 pick × 每个选中 Trial = 一条曲线/一列
    const entries = [];
    const excludedEntries = [];
    picks.forEach((pick, pi) => {
      const sessions = activeStore[pick.athleteId] || [];
      const session = sessions[pick.sIdx] || null;
      const athlete = athletes.find(a => a.id === pick.athleteId);
      if (!athlete || !session) return;
      const cmp = _comparability(session);
      if (!cmp.comparable) { excludedEntries.push({ athlete, reason: cmp.reason }); return; } // limited/invalid excluded
      (pick.tIdxs.length ? pick.tIdxs : [0]).forEach(tIdx => {
        const trial = session.trials?.[tIdx] ?? null;
        entries.push({ id: `${pick.athleteId}|${pick.sIdx}|${tIdx}|${pi}`, athleteId: pick.athleteId, athlete, session, sessions, sIdx: pick.sIdx, trial, tIdx });
      });
    });
    entries.forEach((e, i) => { e.color = COMPARE_PALETTE[i % COMPARE_PALETTE.length]; });
    const colorOf = {}; entries.forEach(e => { colorOf[e.id] = e.color; });

    const TYPE_COLORS = { "Ⅰ": "#34d399", "Ⅱ": "#fbbf24", "Ⅲ": "#60a5fa", "Ⅳ": "#f87171" };
    const colW = Math.max(80, Math.min(120, Math.floor(420 / Math.max(entries.length, 1))));
    const nameW = 150;

    // Metric table rows from selected trial per athlete
    const tableRows = compareMetricKeys.map(key => {
      const def = activeMetrics.find(m => m.key === key);
      if (!def) return null;
      const vals = entries.map(e => {
        const v = e.trial?.metrics?.[key];
        return (v != null && isFinite(v)) ? v : null;
      });
      const valid = vals.filter(v => v != null);
      if (!valid.length) return null;
      const mean = valid.reduce((a, b) => a + b, 0) / valid.length;
      const sd = valid.length > 1 ? Math.sqrt(valid.reduce((a, v) => a + (v - mean) ** 2, 0) / (valid.length - 1)) : 0;
      let best = null, worst = null;
      if (def.better === "higher") { best = Math.max(...valid); worst = Math.min(...valid); }
      else if (def.better === "lower") { best = Math.min(...valid); worst = Math.max(...valid); }
      const prec = valid.reduce((p, v) => {
        const s = String(v.toFixed(4)); const d = s.includes(".") ? s.split(".")[1].replace(/0+$/, "").length : 0;
        return Math.max(p, Math.min(d, 3));
      }, 1);
      return { ...def, vals, mean, sd, best, worst, prec, fmt: v => v != null ? v.toFixed(prec) : "—" };
    }).filter(Boolean);

    // ── Chart helpers ─────────────────────────────────────────────────────────
    // Shared chart builder: returns axis-scale functions and auto-computed tick arrays
    const makeScale = (vals, padFrac, minFloor) => {
      if (!vals.length) return { lo: 0, hi: 1 };
      let lo = Math.min(...vals), hi = Math.max(...vals);
      const pad = (hi - lo) * padFrac || Math.abs(hi) * padFrac || 0.05;
      lo -= pad; hi += pad;
      if (minFloor != null) lo = Math.max(lo, minFloor);
      return { lo, hi };
    };
    const niceTicks = (lo, hi, n) => {
      const span = hi - lo || 1;
      const raw  = span / (n - 1);
      const mag  = Math.pow(10, Math.floor(Math.log10(raw)));
      const step = [1, 2, 2.5, 5, 10].map(f => f * mag).find(s => s >= raw) || raw;
      const tks  = [];
      for (let v = Math.ceil(lo / step) * step; v <= hi + step * 0.01; v += step)
        tks.push(+v.toFixed(10));
      return tks;
    };

    return (
      <div className="fc-page" data-force-compare-page>

        <div className="fc-head">
          <strong>力板对比 · 同人多场次 / 跨运动员 · 多 Trial 叠加</strong>
          {/* Test-type tabs */}
          <div style={{ display: "flex", gap: 2, background: "var(--panel-2)", borderRadius: 7, padding: 3, border: "1px solid var(--border)" }}>
            {Object.entries(TEST_CONFIGS).map(([id, c]) => (
              <button key={id} onClick={() => setTestType(id)} style={{
                padding: "4px 14px", borderRadius: 5, border: "none", cursor: "pointer", fontSize: 12,
                fontFamily: "var(--font-sans)", fontWeight: testType === id ? 600 : 400,
                background: testType === id ? "var(--panel)" : "transparent",
                color: testType === id ? "var(--text)" : "var(--muted)",
                boxShadow: testType === id ? "0 1px 3px rgba(0,0,0,.12)" : "none",
                transition: "all .12s",
              }}>{c.label}</button>
            ))}
          </div>
          <span style={{ fontSize: 10, color: "var(--muted)", background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 4, padding: "2px 8px", letterSpacing: ".04em", textTransform: "uppercase" }}>
            Selected trial · Session snapshot
          </span>
        </div>

        {excludedEntries.length > 0 && (
          <div style={{ fontSize: 11, color: "var(--danger, #b45309)", background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8, padding: "8px 12px" }}>
            ⚠ {excludedEntries.length} 个受限会话已排除出对比（{excludedEntries[0].reason || "采样率不足 / 时间轴异常 · 不可跨会话比较"}）
          </div>
        )}

        {/* Athlete + session + trial selector */}
        <section className="fc-setup-card">
          <div style={{ fontSize: 10, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 10 }}>选择对比项（可重复添加 · 同人多场次 / 跨运动员,最多 12 条;Trial 可多选叠加）</div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {athletes.map(a => {
              const hasSessions = (activeStore[a.id] || []).length > 0;
              const cnt = picks.filter(p => p.athleteId === a.id).length;
              return (
                <button key={a.id} onClick={() => hasSessions && addPick(a.id)} style={{
                  display: "inline-flex", alignItems: "center", gap: 6,
                  padding: "5px 10px", borderRadius: 6, cursor: hasSessions ? "pointer" : "not-allowed",
                  opacity: hasSessions ? 1 : 0.4,
                  background: cnt > 0 ? "var(--panel-hi)" : "var(--panel-2)",
                  border: "1px solid " + (cnt > 0 ? "var(--border-strong)" : "var(--border)"),
                  color: cnt > 0 ? "var(--text)" : "var(--text-2)", fontFamily: "var(--font-sans)", fontSize: 12,
                }}>
                  <span style={{ color: "var(--muted-2)" }}>+</span>
                  {a.name}
                  {!hasSessions && <span style={{ fontSize: 10, color: "var(--muted-2)" }}>(无数据)</span>}
                  {cnt > 0 && <span style={{ fontSize: 9.5, color: "#fff", background: "var(--accent)", borderRadius: 999, padding: "0 5px", fontWeight: 700 }}>{cnt}</span>}
                  {hasSessions && cnt === 0 && <span style={{ fontSize: 10, color: "var(--muted-2)" }}>{(activeStore[a.id] || []).length}次</span>}
                </button>
              );
            })}
          </div>

          {picks.length > 0 && (
            <div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--border)", display: "flex", flexDirection: "column", gap: 8 }}>
              {picks.map((pick, pi) => {
                const sessions = activeStore[pick.athleteId] || [];
                const session = sessions[pick.sIdx] || null;
                const athlete = athletes.find(a => a.id === pick.athleteId);
                if (!athlete) return null;
                return (
                  <div key={pi} style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                    <button onClick={() => removePick(pi)} title="移除" style={{ width: 18, height: 18, borderRadius: 4, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--muted)", cursor: "pointer", lineHeight: 1, flexShrink: 0 }}>×</button>
                    <span style={{ fontSize: 11, fontWeight: 700, minWidth: 90, whiteSpace: "nowrap", color: "var(--text-2)" }}>{athlete.name}</span>
                    <select value={pick.sIdx}
                      onChange={ev => setPickSession(pi, +ev.target.value)}
                      style={{ fontSize: 11, background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 4, color: "var(--text)", padding: "3px 6px", fontFamily: "var(--font-sans)" }}>
                      {sessions.map((s, si) => {
                        const cmp = _comparability(s); // limited/invalid → visible but non-selectable (GPT audit P1.2)
                        return <option key={s.id} value={si} disabled={!cmp.comparable}>{cfg.sessionLabel(s)}{!cmp.comparable ? ' · 采样率不足（不可比）' : ''}</option>;
                      })}
                    </select>
                    <div style={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
                      {(session?.trials || []).map((tr, ti) => {
                        const active = pick.tIdxs.includes(ti);
                        const ec = colorOf[`${pick.athleteId}|${pick.sIdx}|${ti}|${pi}`] || "var(--accent)";
                        const tc = TYPE_COLORS[tr.type] || "var(--muted)";
                        return (
                          <button key={ti} onClick={() => togglePickTrial(pi, ti)} title="多选可叠加" style={{
                            fontSize: 10.5, padding: "2px 8px", borderRadius: 4, cursor: "pointer",
                            background: active ? (ec + "22") : "transparent",
                            border: "1px solid " + (active ? ec : "var(--border)"),
                            color: active ? ec : "var(--muted)", fontFamily: "var(--font-sans)",
                          }}>
                            T{tr.index}<span style={{ marginLeft: 3, fontSize: 9.5, color: tc }}>{tr.type}</span>
                          </button>
                        );
                      })}
                    </div>
                    <button onClick={() => onNavigate && onNavigate(testType, pick.athleteId)} style={{
                      fontSize: 10, color: "var(--muted)", background: "none",
                      border: "1px solid var(--border)", borderRadius: 4, padding: "2px 6px", cursor: "pointer", marginLeft: "auto",
                    }}>+ 上传新数据</button>
                  </div>
                );
              })}
            </div>
          )}
        </section>

        {entries.length === 0 ? (
          <div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--muted)", fontSize: 13 }}>
            <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 18 Q8 4 12 10 Q16 16 21 4"/>
            </svg>
            {cfg.emptyHint}，或从上方选择已有数据的运动员
          </div>
        ) : (
          <>
            {/* ── Overlay charts ──────────────────────────────────────────────── */}
            {/* Shared legend */}
            <div className="fc-legend">
              {entries.filter(e => e.trial?.curve).map(e => (
                <span key={e.id} style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "var(--text-2)" }}>
                  <span style={{ width: 22, height: 2.5, background: e.color, borderRadius: 2, display: "inline-block" }}/>
                  <span style={{ color: e.color, fontWeight: 600 }}>{e.athlete.name}</span>
                  <span style={{ color: "var(--muted)", fontSize: 10 }}>{e.session?.date} · T{e.trial?.index} · {e.trial?.type}</span>
                </span>
              ))}
            </div>

            <div className="fc-chart-layout" data-force-compare-chart-layout>

            {/* F-t normalised overlay — full width, with a–g key-point labels */}
            {entries.some(e => e.trial?.curve?.t?.length) && (() => {
              // Layout mirrors the Analysis CMJChart normalized view
              const W = 760, H = 320, ML = 52, MR = 18, MT = 22, MB = 42;
              const PW = W - ML - MR, PH = H - MT - MB;
              // Auto Y scale (with bottom padding so the bottom of the curve isn't clipped by the axis)
              const allF = entries.flatMap(e => e.trial?.curve?.f || []).filter(isFinite);
              const fScale = makeScale(allF, 0.1, null);
              const yS = f => MT + PH * (1 - (f - fScale.lo) / (fScale.hi - fScale.lo));
              const xS = t => ML + t * PW;
              const yTks = niceTicks(fScale.lo, fScale.hi, 6);
              const xTks = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];

              // For each entry, locate the index in the stored curve nearest to each key-point fraction
              const nearestIdx = (tArr, target) => {
                let bestI = 0, bestD = Infinity;
                for (let i = 0; i < tArr.length; i++) {
                  const d = Math.abs(tArr[i] - target);
                  if (d < bestD) { bestD = d; bestI = i; }
                }
                return bestI;
              };

              return (
                <section className="fc-chart-card fc-chart-card-normalized" data-force-compare-chart="norm">
                  <div style={{ fontSize: 10, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", fontWeight: 600, marginBottom: 10 }}>
                    归一化力时序  ·  GRF / BW  (onset → takeoff = 0–100%)
                  </div>
                  <svg viewBox={"0 0 " + W + " " + H} style={{ width: "100%", height: "auto", display: "block", overflow: "visible" }}>
                    {/* Grid */}
                    {yTks.map(y => (
                      <g key={y}>
                        <line x1={ML} y1={yS(y).toFixed(1)} x2={ML + PW} y2={yS(y).toFixed(1)} stroke="rgba(15,23,42,.06)" strokeWidth="1"/>
                        <text x={ML - 6} y={+yS(y).toFixed(1) + 3} textAnchor="end" fontSize="10.5" fill="var(--muted)">{y.toFixed(1)}</text>
                      </g>
                    ))}
                    {xTks.map(t => (
                      <g key={t}>
                        <line x1={xS(t).toFixed(1)} y1={MT} x2={xS(t).toFixed(1)} y2={MT + PH} stroke="rgba(15,23,42,.05)" strokeWidth="1"/>
                        <text x={xS(t).toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="10.5" fill="var(--muted)">{(t * 100).toFixed(0)}%</text>
                      </g>
                    ))}
                    {/* 100% BW reference (1.0 in GRF/BW units) */}
                    {fScale.lo < 1 && fScale.hi > 1 && (
                      <>
                        <line x1={ML} y1={yS(1).toFixed(1)} x2={ML + PW} y2={yS(1).toFixed(1)} stroke="rgba(15,23,42,.22)" strokeWidth="1" strokeDasharray="5 3"/>
                        <text x={ML + PW - 2} y={+yS(1).toFixed(1) - 4} textAnchor="end" fontSize="7.5" fill="rgba(255,255,255,.32)">1.0 · BW</text>
                      </>
                    )}
                    {/* Axes */}
                    <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="rgba(15,23,42,.12)"/>
                    <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.12)"/>
                    {/* Curves */}
                    {entries.map((e, ei) => {
                      const c = e.trial?.curve;
                      if (!c?.t?.length) return null;
                      const screenPts = c.t.map((t, i) => [xS(t), yS(c.f[i])]);
                      const d = screenPts.map(([x, y], i) => (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1)).join("");
                      return (
                        <path key={e.id} d={d} fill="none" stroke={e.color} strokeWidth="1.8" strokeOpacity=".9" strokeLinejoin="round" strokeLinecap="round"/>
                      );
                    })}
                    {/* Key-point markers a–g per entry (drawn AFTER curves so they sit on top) */}
                    {entries.map(e => {
                      const c = e.trial?.curve;
                      const kp = e.trial?.keyPts;
                      if (!c?.t?.length || !kp) return null;
                      const labels = ["a", "b", "c", "d", "e", "f", "g"];
                      return (
                        <g key={"kp-" + e.id}>
                          {labels.map(lab => {
                            const t = kp[lab];
                            if (t == null) return null;
                            const i  = nearestIdx(c.t, t);
                            const cx = xS(c.t[i]);
                            const cy = yS(c.f[i]);
                            // Place label above the marker if curve is in lower half, below otherwise
                            const above = cy > MT + PH * 0.5;
                            return (
                              <g key={lab}>
                                <circle cx={cx.toFixed(1)} cy={cy.toFixed(1)} r="3"
                                  fill="var(--bg)" stroke={e.color} strokeWidth="1.4"/>
                                <text x={cx.toFixed(1)} y={(cy + (above ? -7 : 11)).toFixed(1)}
                                  textAnchor="middle" fontSize="11" fontWeight="700" fontStyle="italic"
                                  fill={e.color}>{lab}</text>
                              </g>
                            );
                          })}
                        </g>
                      );
                    })}
                    {/* Axis labels */}
                    <text x="14" y={MT + PH / 2} textAnchor="middle" fontSize="11" fill="var(--muted)" transform={"rotate(-90,14," + (MT + PH / 2) + ")"}>GRF / BW</text>
                    <text x={ML + PW / 2} y={H - 6} textAnchor="middle" fontSize="11" fill="var(--muted)">Normalised time  (onset → takeoff)</text>
                  </svg>
                  {/* Key-point legend */}
                  <div style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", marginTop: 8, fontSize: 10, color: "var(--muted)" }}>
                    {[
                      ["a", "onset"], ["b", "peak unloading"], ["c", "min velocity"],
                      ["d", "peak prop. force"], ["e", "zero-cross"], ["f", "peak velocity"], ["g", "takeoff"],
                    ].map(([k, v]) => (
                      <span key={k}><i style={{ color: "var(--text-2)", fontWeight: 700 }}>{k}</i> {v}</span>
                    ))}
                  </div>
                </section>
              );
            })()}

            {/* FD Loop + FV Loop — side by side, using the same renderer as Analysis */}
            {entries.some(e => e.trial?.curve?.t?.length) && <div className="fc-loop-grid">
              {["fd", "fv"].map(mode => (
                <section key={mode} className="fc-chart-card" data-force-compare-chart={mode}>
                  <CompareLoopChart mode={mode} entries={entries}/>
                </section>
              ))}
            </div>}
            </div>

            {/* Metrics table */}
            <section className="fc-metrics-card">
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px 8px", borderBottom: "1px solid var(--border)" }}>
                <span style={{ fontSize: 10, color: "var(--muted)", textTransform: "uppercase", letterSpacing: ".06em", fontWeight: 600 }}>指标对比（选定试次）</span>
                <button onClick={() => setPickerOpen(o => !o)} style={{
                  display: "inline-flex", alignItems: "center", gap: 5, fontSize: 10.5,
                  color: pickerOpen ? "var(--accent)" : "var(--muted)",
                  background: pickerOpen ? "rgba(59,130,246,.08)" : "transparent",
                  border: "1px solid " + (pickerOpen ? "rgba(59,130,246,.3)" : "var(--border)"),
                  borderRadius: 5, padding: "3px 8px", cursor: "pointer", fontFamily: "var(--font-sans)",
                }}>⚙ 自定义指标</button>
              </div>

              {pickerOpen && (
                <div style={{ margin: "10px 14px", padding: "12px", background: "var(--bg)", borderRadius: 8, border: "1px solid var(--border)" }}>
                  {activeSections.map(sec => (
                    <div key={sec} style={{ marginBottom: 10 }}>
                      <div style={{ fontSize: 9, color: SECTION_COLORS[sec] || "var(--muted)", fontWeight: 700, textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 5 }}>{sec}</div>
                      <div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px" }}>
                        {activeMetrics.filter(m => m.section === sec).map(m => {
                          const checked = compareMetricKeys.includes(m.key);
                          return (
                            <label key={m.key} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 11, color: checked ? "var(--text-2)" : "var(--muted)", cursor: "pointer", userSelect: "none" }}>
                              <input type="checkbox" checked={checked}
                                onChange={() => setCompareMetricKeys(prev => prev.includes(m.key) ? prev.filter(k => k !== m.key) : [...prev, m.key])}
                                style={{ accentColor: SECTION_COLORS[sec] || "#60a5fa", width: 12, height: 12 }}/>
                              {m.label}
                              {m.unit && <span style={{ fontSize: 9.5, color: "var(--muted-2)" }}>{m.unit}</span>}
                            </label>
                          );
                        })}
                      </div>
                    </div>
                  ))}
                </div>
              )}

              <div style={{ overflowX: "auto", padding: "4px 0 10px" }}>
                <div style={{ display: "flex", alignItems: "flex-end", borderBottom: "1px solid var(--border)", paddingBottom: 4, minWidth: "max-content", paddingTop: 4 }}>
                  <div style={{ width: nameW, minWidth: nameW, fontSize: 9.5, color: "transparent", padding: "0 8px 0 14px" }}>指标</div>
                  {entries.map(e => (
                    <div key={e.id} style={{ width: colW, minWidth: colW, fontSize: 9.5, color: e.color, fontWeight: 700, textAlign: "right", padding: "0 8px", whiteSpace: "nowrap", flexShrink: 0 }}>
                      {e.athlete.name}
                      {e.trial && <div style={{ fontSize: 9, color: "var(--muted)", fontWeight: 400 }}>{e.session?.date} · T{e.trial.index} · {e.trial.type}</div>}
                    </div>
                  ))}
                  <div style={{ width: 56, minWidth: 56, fontSize: 9.5, color: "rgba(52,211,153,.8)", fontWeight: 600, textAlign: "right", padding: "0 8px", whiteSpace: "nowrap", flexShrink: 0 }}>Best</div>
                  <div style={{ width: 60, minWidth: 60, fontSize: 9.5, color: "var(--muted)", fontWeight: 600, textAlign: "right", padding: "0 14px 0 8px", whiteSpace: "nowrap", flexShrink: 0 }}>SD</div>
                </div>

                {tableRows.map((row, ri) => {
                  const secColor = SECTION_COLORS[row.section] || "var(--muted)";
                  return (
                    <div key={row.key} style={{ display: "flex", alignItems: "center", minWidth: "max-content",
                      background: ri % 2 === 0 ? "transparent" : "rgba(15,23,42,.03)",
                      borderBottom: "1px solid rgba(15,23,42,.05)" }}>
                      <div style={{ width: nameW, minWidth: nameW, padding: "5px 8px 5px 14px", whiteSpace: "nowrap" }}>
                        <span style={{ fontSize: 9, color: secColor, fontWeight: 700, marginRight: 4, background: secColor + "1a", borderRadius: 2, padding: "1px 3px" }}>{row.section}</span>
                        <span style={{ fontSize: 11, color: "var(--text-2)" }}>{row.label}</span>
                        {row.unit && <span style={{ fontSize: 9.5, color: "var(--muted-2)", marginLeft: 3 }}>{row.unit}</span>}
                      </div>
                      {entries.map((e, ei) => {
                        const val = row.vals[ei];
                        const isBest  = row.best  != null && val != null && val === row.best;
                        const isWorst = row.worst != null && val != null && val === row.worst && val !== row.best;
                        const marker = isBest ? "▲" : isWorst ? "▼" : "";
                        return (
                          <div key={e.id} style={{ width: colW, minWidth: colW, fontSize: 11.5, textAlign: "right", padding: "5px 8px",
                            fontFamily: "var(--font-mono, monospace)", whiteSpace: "nowrap", flexShrink: 0,
                            color: isBest ? "rgba(52,211,153,.95)" : isWorst ? "rgba(248,113,113,.75)" : e.color,
                            fontWeight: isBest ? 700 : 400, opacity: val == null ? 0.3 : 1 }}>
                            {marker && <span style={{ fontSize: 8, marginRight: 2, opacity: .8 }} title={isBest ? "Best across athletes" : "Worst across athletes"}>{marker}</span>}
                            {val != null ? row.fmt(val) : "—"}
                          </div>
                        );
                      })}
                      <div style={{ width: 56, minWidth: 56, fontSize: 11.5, textAlign: "right", padding: "5px 8px",
                        fontFamily: "var(--font-mono)", flexShrink: 0,
                        color: row.best != null ? "rgba(52,211,153,.8)" : "var(--muted-2)", fontWeight: 600 }}>
                        {row.best != null ? row.fmt(row.best) : "—"}
                      </div>
                      <div style={{ width: 60, minWidth: 60, fontSize: 11.5, textAlign: "right", padding: "5px 14px 5px 8px",
                        fontFamily: "var(--font-mono)", flexShrink: 0, color: "rgba(15,23,42,.4)" }}>
                        {entries.length > 1 ? row.fmt(row.sd) : "—"}
                      </div>
                    </div>
                  );
                })}
              </div>
            </section>
          </>
        )}
      </div>
    );
  }

  window.ForceCompareView = ForceCompareView;
  window.CMJCompareView = ForceCompareView; // backward compat alias
})();
