// cmj.jsx  v25  —  CMJ 力板分析（最完整的力板模块，作为其他模块的参考实现）
// 职责：算法（Butterworth / findBestQuietWindow / 相位检测）· 分析面板 · session 持久化（localStorage）· 纵向视图 · 多人比较
// P2-A: Output MCard 重排 — mRSI(1st) → relPeakPower(2nd) → TTT(3rd) → JH(4th)；纵向视图默认指标同步
// 依赖：无内部依赖 · session key: cmj_sess_{athleteId}
// 说明：腾空/落地检测已改用原始信号，避免零相位滤波在 20N 阈值附近造成时间 smear。
// Supported formats:
//   · VALD ForceDecks  — .xlsx / .csv / .tsv  (has Weight/Frequency/Time header rows)
//   · General / CM     — .csv  (Name,Unit,Start_time,Time_increment,Length metadata block;
//                                no time column; BW auto-estimated from quiet-standing phase)
// Multi-jump detection: processes all valid CMJ trials in a single recording.
// Each jump integrates independently from its own quiet-standing reference → no drift.

(function () {
  const { useState, useCallback, useRef, useEffect } = React;

  const G = 9.81;
  const TAKEOFF_N = 20; // N — below this = airborne
  let forceCoreParser = null;
  const forceCoreParserOptions = {
    allowMarsSummary: true,
    vald: {
      minRows: 500,
      weightMissingMessage: 'Body weight not found in file header. Expected a "Weight" row.',
      insufficientRowsMessage: count => `Only ${count} data rows — too few for analysis. Check this is a raw force data export (not a summary).`,
    },
    general: {
      minRows: 500,
      headerMissingMessage: '未能识别数据表头行。请确认这是力板原始 CSV 导出文件（包含 Name/Unit/Time_increment 元数据块）。',
      insufficientRowsMessage: count => `只找到 ${count} 行数据，不足以分析（最少需要 500 行）。请确认文件包含完整的力-时间曲线原始数据。`,
      unstableStandingMessage: sd => `未找到稳定的静止站立期（最佳 300ms 窗口 SD = ${sd.toFixed(1)}N，> 25N 阈值）。请确认录制开头有 ≥ 300ms 的静止站立。`,
    },
    xlsxMissingMessage: 'XLSX library not loaded. Refresh the page and try again.',
    readErrorMessage: 'Failed to read file.',
  };
  document.addEventListener('sports-os:force-core-parser-ready', ev => { forceCoreParser = ev.detail; });
  try { document.dispatchEvent(new CustomEvent('sports-os:force-core-parser-request')); } catch {}
  let forceCoreFilter = null;
  document.addEventListener('sports-os:force-core-filter-ready', ev => { forceCoreFilter = ev.detail; });
  try { document.dispatchEvent(new CustomEvent('sports-os:force-core-filter-request')); } catch {}
  let forceCoreQuietWindow = null;
  document.addEventListener('sports-os:force-core-quiet-window-ready', ev => { forceCoreQuietWindow = ev.detail; });
  try { document.dispatchEvent(new CustomEvent('sports-os:force-core-quiet-window-request')); } catch {}
  let forceCoreBodyweight = null;
  document.addEventListener('sports-os:force-core-bodyweight-ready', ev => { forceCoreBodyweight = ev.detail; });
  try { document.dispatchEvent(new CustomEvent('sports-os:force-core-bodyweight-request')); } catch {}

  // ── 1. FILE PARSER ────────────────────────────────────────────────────────

  function parseVALDRows(rows) {
    if (forceCoreParser) return forceCoreParser.parseVALDRows(rows, forceCoreParserOptions.vald);
    const meta = { weight: null, frequency: 1000, date: '', athleteId: '' };
    let headerRow = -1;

    for (let i = 0; i < Math.min(rows.length, 30); i++) {
      const key = String(rows[i][0] ?? '').trim();
      if      (key === 'Weight')         meta.weight    = parseFloat(rows[i][1]);
      else if (key === 'Frequency')      meta.frequency = parseInt(rows[i][1]) || 1000;
      else if (key === 'Recording Date') meta.date      = String(rows[i][1] ?? '').trim();
      else if (key === 'AthleteId')      meta.athleteId = String(rows[i][1] ?? '').trim();
      else if (key === 'Time')           { headerRow = i; break; }
    }

    if (headerRow < 0)
      throw new Error('未找到数据表头（Time / Left / Right）。请确认上传的是力板原始数据导出文件。');
    if (!meta.weight)
      throw new Error('Body weight not found in file header. Expected a "Weight" row.');

    const time = [], left = [], right = [];
    for (let i = headerRow + 1; i < rows.length; i++) {
      const t = parseFloat(rows[i][0]);
      const l = parseFloat(rows[i][1]);
      const r = parseFloat(rows[i][2]);
      if (isNaN(t) || isNaN(l) || isNaN(r)) continue;
      time.push(t); left.push(l); right.push(r);
    }

    if (time.length < 500)
      throw new Error(`Only ${time.length} data rows — too few for analysis. Check this is a raw force data export (not a summary).`);

    return { meta, time, left, right };
  }

  // ── FORMAT DETECTION ────────────────────────────────────────────────────────
  // Returns 'mars_summary' for MARS computed-metrics export (Subject-Name header row),
  //         'general'      for Name/Unit/Time_increment raw format,
  //         'vald'         for VALD ForceDecks format (default fallback).
  function detectRowsFormat(rows) {
    if (forceCoreParser) return forceCoreParser.detectRowsFormat(rows, forceCoreParserOptions);
    if (!rows || rows.length < 2) return 'vald';
    const r0 = rows[0].map(c => String(c ?? '').trim());
    if (r0.some(c => c === 'Subject - Name')) return 'mars_summary';
    if (r0[0] === 'Name' && r0.some(c => c === 'Time_increment')) return 'general';
    return 'vald';
  }

  // ── MARS SUMMARY FORMAT PARSER ───────────────────────────────────────────────
  // Handles MARS computed-metrics export xlsx:
  //   Row 0 : column headers (Subject - Name, General Parameters --- ..., etc.)
  //   Rows 1…N: one row per rep, each column a pre-computed metric
  // No raw force-time data — produces synthetic jump objects with metrics only.
  function parseMARSSummaryRows(rows) {
    const hdrs = rows[0].map(c => String(c ?? '').toLowerCase().trim());
    const col = (...kws) => {
      for (let i = 0; i < hdrs.length; i++)
        if (kws.every(k => hdrs[i].includes(k.toLowerCase()))) return i;
      return -1;
    };
    const fv  = (r, c) => { const v = c >= 0 ? parseFloat(r[c]) : NaN; return isNaN(v) ? null : v; };
    const fmt = (v, d = 1) => v != null ? +v.toFixed(d) : null;
    // MARS L/R ratio (L/R × 100) → Dashboard asymmetry (L−R)/(L+R) × 100
    const lrAsym = pct => pct != null ? (pct - 100) / (pct + 100) * 100 : null;

    const cName    = col('subject', 'name');
    const cWeight  = col('subject', 'weight');
    const cRep     = col('repetition');
    const cJhImp   = col('jump height from take off v');
    const cJhFT    = col('jump height from flight t');
    const cVto     = col('vertical take off v');
    const cJumpT   = col('jump t [s]');
    const cCMT     = col('counter movement t [s]');
    const cPushT   = col('push off t [s]');
    const cFlightT = col('flight t [s]');
    const cBrakT   = col('braking t [s]');
    const cRelPF   = col('relative maximal f during push off');
    const cRelPFCM = col('relative maximal f during counter movement');
    const cRelPP   = col('relative maximal p [w/kg]');
    const cPushFI  = col('push off fi [ns]');
    const cNegFI   = col('negative fi');
    const cPosFI   = col('positive fi');
    const cEccFI   = col('eccentric deceleration phase fi [ns]');
    const cP1FI    = col('fi in the 1st half of push off');
    const cP2FI    = col('fi in the 2nd half of push off');
    const cLRPush  = col('left/right leg - push off fi [%]');
    const cLREcc   = col('left/right leg - eccentric deceleration phase fi');
    const cEccRFD  = col('max eccentric rfd - standard');

    const jumps = [];
    for (let i = 1; i < rows.length; i++) {
      const r = rows[i];
      if (cName < 0 || !r[cName]) continue;

      const jhImp_m = fv(r, cJhImp);
      const jhFT_m  = fv(r, cJhFT);
      const jumpT   = fv(r, cJumpT);
      const cmT     = fv(r, cCMT);
      const brakT   = fv(r, cBrakT);
      const pushFI  = fv(r, cPushFI);
      const negFI   = fv(r, cNegFI);
      const posFI   = fv(r, cPosFI);
      const p1FI    = fv(r, cP1FI);
      const p2FI    = fv(r, cP2FI);

      const metrics = {
        jumpHeight:          jhImp_m != null ? fmt(jhImp_m * 100, 1) : null,
        jumpHeightFT:        jhFT_m  != null ? fmt(jhFT_m  * 100, 1) : null,
        takeoffVelocity:     fmt(fv(r, cVto), 3),
        ttt:                 fmt(jumpT, 3),
        rsiMod:              jhImp_m != null && jumpT ? fmt(jhImp_m / jumpT, 3) : null,
        flightTime:          fmt(fv(r, cFlightT), 3),
        cmDepth:             null,
        peakAcc:             null,
        unweightingTime:     cmT != null && brakT != null ? fmt(cmT - brakT, 3) : null,
        brakingTime:         fmt(brakT, 3),
        propulsiveTime:      fmt(fv(r, cPushT), 3),
        ttoPct:              null,
        relPeakPropForce:    fmt(fv(r, cRelPF),  1),
        relPeakBrakingForce: fmt(fv(r, cRelPFCM), 1),
        relPeakPower:        fmt(fv(r, cRelPP), 2),
        propNetImpulse:      fmt(pushFI, 1),
        brakingNetImpulse:   fmt(negFI,  1),
        propImpulse:         fmt(posFI,  1),
        eccDecFI:            fmt(fv(r, cEccFI), 1),
        p1Impulse:           fmt(p1FI, 1),
        p2Impulse:           fmt(p2FI, 1),
        p1p2Ratio:           p1FI != null && p2FI ? fmt(p1FI / p2FI, 2) : null,
        impulseRatio:        pushFI != null && negFI ? fmt(pushFI / Math.abs(negFI), 2) : null,
        asymProp:            fmt(lrAsym(fv(r, cLRPush)), 1),
        asymBraking:         fmt(lrAsym(fv(r, cLREcc)),  1),
        brakingRFD:          fmt(fv(r, cEccRFD), 0),
      };

      const repNum = cRep >= 0 ? (parseInt(r[cRep]) || i) : i;
      jumps.push({ index: repNum, phases: null, metrics, vel: [], disp: [], quietRef: 0 });
    }

    if (jumps.length === 0)
      throw new Error('MARS 汇总文件中未找到有效数据行。请确认文件包含运动员数据行。');

    const firstRow = rows[1];
    const athleteName = cName >= 0 ? String(firstRow[cName] ?? '').trim() : '';
    const dateVal  = firstRow[0];
    const dateStr  = dateVal instanceof Date
      ? dateVal.toISOString().slice(0, 10)
      : String(dateVal ?? '').slice(0, 10);
    const weight_kg = cWeight >= 0 ? parseFloat(firstRow[cWeight] ?? 0) : 0;

    return {
      // frequency: null — a MARS summary has no raw axis, so the declared rate is unknown, not
      // a fabricated 1000 (GPT audit). The SamplingProfile reports source=external-precomputed.
      meta: { weight: weight_kg, frequency: null, date: dateStr,
              athleteId: athleteName, isMARSSummary: true },
      time: [], left: [], right: [],
      _precomputedJumps: jumps,
    };
  }

  // ── GENERAL FORMAT PARSER ────────────────────────────────────────────────────
  // Handles files with metadata block:
  //   Row 0 : Name,Unit,Start_time,Time_increment,Length
  //   Rows 1…N: channel metadata (one row per channel)
  //   Empty row
  //   Data header row: channel names (e.g. General_CM2:Fz-L, General_CM1:Fz-R, General_Fz)
  //   Data rows: force values in Newtons (no time column — reconstructed from Time_increment)
  //
  // Body weight is NOT in the file; it is estimated by finding the quietest 500 ms window
  // in the first 5 seconds of data and using its mean as the standing body weight.
  function parseGeneralRows(rows) {
    if (forceCoreParser) return forceCoreParser.parseGeneralRows(rows, forceCoreParserOptions.general);
    let timeIncrement = 0.001;
    let startDate     = '';
    let dataHeaderRow = -1;

    // ── Step 1: parse metadata rows, then locate data header via empty-row separator
    // Format structure:
    //   Row 0 : Name,Unit,Start_time,Time_increment,Length   ← column labels
    //   Rows 1…N: one row per channel  [Name, Unit, StartTime, TimeIncrement, Length]
    //   Empty row  ← separator
    //   Data header row: channel names (e.g. "Fz-L,Fz-R,Fz")
    //   Data rows
    let foundSeparator = false;
    for (let i = 1; i < Math.min(rows.length, 40); i++) {
      const first = String(rows[i][0] ?? '').trim();

      if (!first) {
        // Empty row = separator between metadata block and data
        foundSeparator = true;
        continue;
      }

      if (foundSeparator) {
        // First non-empty row after the empty separator is the data header
        dataHeaderRow = i;
        break;
      }

      // Still in metadata block — extract Time_increment (col 3) and Start_time (col 2)
      if (rows[i].length >= 4) {
        const inc = parseFloat(rows[i][3]);
        if (!isNaN(inc) && inc > 0 && inc < 1) timeIncrement = inc;
        if (!startDate && rows[i][2]) startDate = String(rows[i][2]).trim();
      }
    }

    if (dataHeaderRow < 0)
      throw new Error('未能识别数据表头行。请确认这是力板原始 CSV 导出文件（包含 Name/Unit/Time_increment 元数据块）。');

    // ── Step 2: map columns from data header
    const rawHeaders = rows[dataHeaderRow].map(c => String(c ?? '').trim());
    const headers    = rawHeaders.map(h => h.toLowerCase());
    let leftCol = -1, rightCol = -1, totalCol = -1;

    headers.forEach((h, i) => {
      if      (h.includes('fz-l') || h.includes('fzl') || h.includes('left'))  leftCol  = i;
      else if (h.includes('fz-r') || h.includes('fzr') || h.includes('right')) rightCol = i;
      else if (h.includes('fz')   || h.includes('total') || h.includes('force')) totalCol = i;
    });

    // Positional fallback: assume L, R, Total order when names are unrecognised
    if (leftCol < 0 && rightCol < 0) {
      if (headers.length >= 3) { leftCol = 0; rightCol = 1; totalCol = 2; }
      else if (headers.length >= 1) { totalCol = 0; }
    }

    // ── Step 3: parse data rows
    const left = [], right = [], time = [];
    for (let i = dataHeaderRow + 1; i < rows.length; i++) {
      const row = rows[i];
      if (!row || String(row[0] ?? '').trim() === '') continue;
      const l   = leftCol  >= 0 ? parseFloat(row[leftCol])  : NaN;
      const r   = rightCol >= 0 ? parseFloat(row[rightCol]) : NaN;
      const tot = totalCol >= 0 ? parseFloat(row[totalCol]) : NaN;
      if (isNaN(l) && isNaN(r) && isNaN(tot)) continue;
      // If only total is available, split evenly; if only L+R, derive total
      const lVal = isNaN(l) ? (isNaN(tot) ? 0 : tot / 2) : l;
      const rVal = isNaN(r) ? (isNaN(tot) ? 0 : tot / 2) : r;
      left.push(lVal);
      right.push(rVal);
      time.push(time.length * timeIncrement);
    }

    if (time.length < 500)
      throw new Error(`只找到 ${time.length} 行数据，不足以分析（最少需要 500 行）。请确认文件包含完整的力-时间曲线原始数据。`);

    // ── Step 4: estimate body weight from quietest 300 ms window in first 10 s
    // 300 ms (not 500 ms) so short recordings whose quiet phase is < 500 ms can still
    // produce a clean, movement-free BW estimate. Matches the quietSkip used inside
    // detectAllJumps for self-consistency.
    // IMPORTANT: filter out flight-phase windows (total ≈ 0 N) — they have the
    // lowest SD but represent air-time, not body weight. Only accept windows
    // whose mean falls in a plausible bilateral standing range (150–2500 N).
    const total     = left.map((l, i) => l + right[i]);
    const frequency = Math.round(1 / timeIncrement);
    const winN      = Math.round(0.3 * frequency);
    const searchLen = Math.min(Math.round(10 * frequency), total.length - winN);
    const BW_MIN_N  = 150, BW_MAX_N = 2500;
    // Default: use mean of first 300 ms as fallback
    let bestMean = forceCoreBodyweight?.meanForce
      ? forceCoreBodyweight.meanForce(total, 0, winN)
      : total.slice(0, winN).reduce((a, b) => a + b, 0) / winN;
    let bestSD   = Infinity;
    for (let i = 0; i < searchLen; i++) {
      let mean;
      if (forceCoreBodyweight?.meanForce) {
        mean = forceCoreBodyweight.meanForce(total, i, winN);
      } else {
        let sum = 0;
        for (let j = i; j < i + winN; j++) sum += total[j];
        mean = sum / winN;
      }
      if (mean < BW_MIN_N || mean > BW_MAX_N) continue; // skip flight / overload windows
      let ss = 0;
      for (let j = i; j < i + winN; j++) ss += (total[j] - mean) ** 2;
      const sd = Math.sqrt(ss / (winN - 1));
      if (sd < bestSD) { bestSD = sd; bestMean = mean; }
    }

    // P2: Weighing-phase quality gate. If even the quietest 300 ms window has
    // SD > 25 N, the recording lacks a stable static reference — all downstream
    // BW-dependent metrics (onset threshold, mass, net impulse) would be unreliable.
    if (bestSD > 25)
      throw new Error(`未找到稳定的静止站立期（最佳 300ms 窗口 SD = ${bestSD.toFixed(1)}N，> 25N 阈值）。请确认录制开头有 ≥ 300ms 的静止站立。`);

    const weight_kg = forceCoreBodyweight?.massFromBodyweight ? forceCoreBodyweight.massFromBodyweight(bestMean, G) : bestMean / G;

    return {
      meta: {
        weight:          parseFloat(weight_kg.toFixed(2)),
        weightEstimated: true,   // flag shown in UI
        frequency,
        date:      startDate,
        athleteId: '',
      },
      time, left, right,
    };
  }

  function detectDelimiter(sample) {
    if (forceCoreParser) return forceCoreParser.detectDelimiter(sample);
    const tabs   = (sample.match(/\t/g)  || []).length;
    const commas = (sample.match(/,/g)   || []).length;
    return tabs >= commas ? '\t' : ',';
  }

  function parseVALDFile(file) {
    if (forceCoreParser) {
      return forceCoreParser.parseVALDFileParserOnly(file, {
        ...forceCoreParserOptions,
        formatHandlers: { mars_summary: parseMARSSummaryRows },
      });
    }
    return new Promise((resolve, reject) => {
      const isXLSX = /\.xlsx?$/i.test(file.name);

      const dispatchRows = (rows) => {
        const fmt = detectRowsFormat(rows);
        if (fmt === 'mars_summary') return parseMARSSummaryRows(rows);
        if (fmt === 'general') return parseGeneralRows(rows);
        return parseVALDRows(rows);
      };

      if (isXLSX) {
        const reader = new FileReader();
        reader.onload = ev => {
          try {
            if (typeof XLSX === 'undefined')
              throw new Error('XLSX library not loaded. Refresh the page and try again.');
            const wb   = XLSX.read(new Uint8Array(ev.target.result), { type: 'array' });
            const ws   = wb.Sheets[wb.SheetNames[0]];
            const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
            resolve(dispatchRows(rows));
          } catch (err) { reject(err); }
        };
        reader.onerror = () => reject(new Error('Failed to read file.'));
        reader.readAsArrayBuffer(file);
      } else {
        const reader = new FileReader();
        reader.onload = ev => {
          try {
            const text  = ev.target.result;
            const lines = text.split(/\r?\n/);
            const delim = detectDelimiter(lines.slice(0, 10).join('\n'));
            const rows  = lines.map(l => l.split(delim));
            resolve(dispatchRows(rows));
          } catch (err) { reject(err); }
        };
        reader.onerror = () => reject(new Error('Failed to read file.'));
        reader.readAsText(file);
      }
    });
  }

  // ── 2. MULTI-JUMP DETECTION ───────────────────────────────────────────────

  // 2nd-order Butterworth low-pass biquad coefficients (RBJ Audio EQ cookbook form).
  // For CMJ analysis the optimal cutoff is 24-28 Hz (preserves all physiological
  // signal content while suppressing high-frequency plate vibration / electrical noise).
  function butterworthCoeffs(fc, fs) {
    if (forceCoreFilter?.butterworthCoeffs) return forceCoreFilter.butterworthCoeffs(fc, fs);
    const w0 = 2 * Math.PI * fc / fs;
    const cosw = Math.cos(w0), sinw = Math.sin(w0);
    const alpha = sinw / (2 / Math.SQRT2);   // Q = 1/√2 for Butterworth
    const a0 = 1 + alpha;
    return {
      b0: (1 - cosw) / 2 / a0,
      b1: (1 - cosw) / a0,
      b2: (1 - cosw) / 2 / a0,
      a1: -2 * cosw / a0,
      a2: (1 - alpha) / a0,
    };
  }

  // Single-pass IIR biquad (Direct Form I).
  function biquadPass(x, c) {
    if (forceCoreFilter?.biquadPass) return forceCoreFilter.biquadPass(x, c);
    const n = x.length, y = new Array(n);
    let x1 = x[0], x2 = x[0], y1 = x[0], y2 = x[0];  // initial conditions = first sample
    for (let i = 0; i < n; i++) {
      const yi = c.b0 * x[i] + c.b1 * x1 + c.b2 * x2 - c.a1 * y1 - c.a2 * y2;
      y[i] = yi;
      x2 = x1; x1 = x[i];
      y2 = y1; y1 = yi;
    }
    return y;
  }

  // Zero-phase low-pass: forward + reverse → cancels phase shift, doubles slope.
  // Reflection padding at both ends suppresses edge transients.
  function filtfilt(x, fc, fs) {
    if (forceCoreFilter?.filtfilt) return forceCoreFilter.filtfilt(x, fc, fs);
    const c = butterworthCoeffs(fc, fs);
    const n = x.length;
    const padN = Math.min(Math.round(3 * fs / fc), n - 1);
    const padded = new Array(n + 2 * padN);
    for (let i = 0; i < padN; i++) padded[i] = 2 * x[0] - x[padN - i];
    for (let i = 0; i < n; i++) padded[padN + i] = x[i];
    for (let i = 0; i < padN; i++) padded[padN + n + i] = 2 * x[n - 1] - x[n - 2 - i];

    let y = biquadPass(padded, c);
    y.reverse();
    y = biquadPass(y, c);
    y.reverse();
    return y.slice(padN, padN + n);
  }

  function findBestQuietWindow(total, bw_n, fromIdx, toIdx, winN) {
    if (forceCoreQuietWindow?.findBestQuietWindow) return forceCoreQuietWindow.findBestQuietWindow(total, bw_n, fromIdx, toIdx, winN);
    // Gate C: when search window is too narrow OR no candidate passed the ±10% filter,
    // return fallback:true so the caller can substitute the global BW reference
    // instead of trusting an arbitrary (possibly movement-contaminated) window.
    if (toIdx < fromIdx + winN) return { start: fromIdx, sd: 10, fallback: true };
    let bestStart = -1, bestSD = Infinity;

    for (let i = fromIdx; i <= toIdx - winN; i++) {
      let sum = 0;
      for (let j = i; j < i + winN; j++) sum += total[j];
      const mean = sum / winN;
      if (Math.abs(mean - bw_n) / bw_n > 0.10) continue;

      let ss = 0;
      for (let j = i; j < i + winN; j++) ss += (total[j] - mean) ** 2;
      const sd = winN > 1 ? Math.sqrt(ss / (winN - 1)) : 0;  // sample SD
      if (sd < bestSD) { bestSD = sd; bestStart = i; }
      if (sd < 3) break;
    }
    if (bestStart < 0) return { start: fromIdx, sd: 10, fallback: true };
    return { start: bestStart, sd: bestSD, fallback: false };
  }

  function detectAllJumps(time, total, left, right, mass_kg, isShortRecording = false, useOnsetBackshift = true, options = {}) {
    // ONE sampling profile drives the whole detection (GPT audit: one rate). Both fs (filter) and
    // the window/integration dt come from it. A forward non-uniform axis remains analysable by
    // explicit product policy using the median Δt, but is quality-flagged and non-comparable.
    // A non-finite / non-increasing axis still yields no defensible time metrics and is rejected.
    const _sp = (typeof window !== 'undefined' && window.ForceCoreSampling) ? window.ForceCoreSampling.samplingProfile(time, null) : null;
    if (_sp && _sp.status === 'invalid') throw new Error(
      '采样时间轴异常（非递增或非有限），无法生成可信的力台指标，本文件不产出指标。请检查采样时间列或重新导出。');
    const dt   = (_sp && _sp.medianDtMs != null) ? _sp.medianDtMs / 1000 : (time[1] - time[0]);
    const winN = Math.round(1.0 / dt);
    const n    = total.length;
    const bw_n = mass_kg * G;
    const rangeStartSec = Number.isFinite(options.rangeStartSec) ? options.rangeStartSec : null;
    const rangeEndSec   = Number.isFinite(options.rangeEndSec)   ? options.rangeEndSec   : null;
    const detectionMode = options.detectionMode || (rangeStartSec != null || rangeEndSec != null ? 'manual_range' : 'auto');
    const scanStart = rangeStartSec != null
      ? time.findIndex(t => t >= rangeStartSec)
      : 0;
    const scanEnd = rangeEndSec != null
      ? (() => {
          const idx = time.findIndex(t => t >= rangeEndSec);
          return idx >= 0 ? Math.min(n - 1, idx) : n - 1;
        })()
      : n - 1;

    if (scanStart < 0)
      throw new Error('Manual range starts after the end of the recording.');
    if (scanEnd <= scanStart + Math.max(10, Math.round(0.2 / dt)))
      throw new Error('Manual range is too short for CMJ detection. Include the quiet preparation, takeoff, and landing.');

    // P1: zero-phase 24 Hz Butterworth low-pass on the channels used for analysis.
    // Below CMJ Nyquist signal content (≈ 20 Hz), suppresses plate-vibration ringing
    // and electrical noise that would otherwise inflate peakForce / RFD / peakPower.
    // Charts still receive the raw arrays via the caller; only the in-function copies
    // are filtered so downstream metric computation runs on a cleaner signal.
    // P3-A: keep rawTotal for takeoff/landing detection — filtering smears the
    // force-zero crossing by ~40 ms (same fix already applied to SJ v6 Fix B).
    const rawTotal = total;  // reference to unfiltered signal (filtfilt returns a new array)
    // fs from the SAME profile as dt (no first-Δt fallback when a profile exists — an invalid
    // axis was already rejected above, so effectiveHz is a real measured rate here).
    const fs = _sp ? _sp.effectiveHz : Math.round(1 / dt);
    if (fs >= 100) {
      total = filtfilt(total, 24, fs);
      left  = filtfilt(left,  24, fs);
      right = filtfilt(right, 24, fs);
    }

    // Gate B: require sustained presence on the plate (≥ 200 ms within ±15 % of header BW)
    // before declaring firstOnPlate. Single-sample triggers are vulnerable to noise spikes,
    // especially during the step-on / taring transient.
    const stableMin = Math.round(0.2 / dt);
    let firstOnPlate = -1;
    {
      let consec = 0, cand = -1;
      for (let i = scanStart; i <= scanEnd; i++) {
        if (Math.abs(total[i] - bw_n) / bw_n < 0.15) {
          if (consec === 0) cand = i;
          if (++consec >= stableMin) { firstOnPlate = cand; break; }
        } else { consec = 0; cand = -1; }
      }
    }
    if (firstOnPlate < 0)
      throw new Error('Athlete never detected standing on plates. Check that force values are bilateral GRF in Newtons and body weight (kg) is correct in the file header.');

    // Short single-rep recordings (MARS/General format) start with athlete already on plate;
    // use a 300 ms quiet-skip instead of 1 s to avoid skipping past the flight phase.
    const quietSkip = Math.round((isShortRecording ? 0.3 : 1.0) / dt);
    const MIN_ON = firstOnPlate + quietSkip;

    const FLIGHT_MIN = Math.round(0.05 / dt);
    const flights = [];
    let fi = Math.max(MIN_ON, scanStart);
    while (fi <= scanEnd) {
      // P3-A: use rawTotal (unfiltered) for takeoff/landing so filtering smear
      // doesn't shorten detected flight time by ~40 ms (matches SJ v6 Fix B).
      if (rawTotal[fi] < TAKEOFF_N) {
        const flightStart = fi;
        while (fi <= scanEnd && rawTotal[fi] < TAKEOFF_N) fi++;
        if (fi - flightStart >= FLIGHT_MIN) {
          // P2: if recording ended while still airborne (fi reached n without re-touchdown),
          // landing is unknown — null tells downstream metrics to skip flight-time JH
          const landing = fi <= scanEnd ? fi : null;
          flights.push({ takeoff: flightStart, landing });
        }
      } else {
        fi++;
      }
    }

    if (flights.length === 0)
      throw new Error('No valid CMJ trials detected. Ensure the recording contains a countermovement jump with clear takeoff (force < 20 N).');

    const jumps = [];

    for (const { takeoff, landing } of flights) {
      const lookBackFrom = Math.max(firstOnPlate, scanStart, takeoff - Math.round(5.0 / dt));
      const lookBackTo   = Math.max(lookBackFrom, takeoff - quietSkip);
      const { start: quietRef, sd: quietSD_local, fallback: bwFallback } = findBestQuietWindow(
        total, bw_n, lookBackFrom, lookBackTo, quietSkip
      );

      // Gate C: if no valid quiet window was found, fall back to the global header BW
      // instead of averaging an arbitrary movement-contaminated slice.
      let localBW;
      if (bwFallback) {
        localBW = bw_n;
      } else {
        if (forceCoreBodyweight?.bodyweightFromQuietWindow) {
          localBW = forceCoreBodyweight.bodyweightFromQuietWindow(total, quietRef, quietSkip);
        } else {
          localBW = 0;
          for (let k = quietRef; k < quietRef + quietSkip; k++) localBW += total[k];
          localBW /= quietSkip;
        }
      }

      // Self-consistent mass: derive from the same localBW used as the integration reference,
      // so that at rest (F = localBW) the net force evaluates to exactly zero. Avoids the
      // silent ~3-7 % JH underestimation seen on MARS data where the pre-detection BW estimate
      // (parseGeneralRows 500 ms scan) was movement-contaminated.
      const localMass = forceCoreBodyweight?.massFromBodyweight
        ? forceCoreBodyweight.massFromBodyweight(localBW, G)
        : localBW / G;

      const onsetThresh = localBW - Math.max(5 * quietSD_local, 10);

      const onsetSearch = Math.max(firstOnPlate + quietSkip, scanStart, takeoff - Math.round(3.0 / dt));
      const ONSET_MIN   = Math.round(0.030 / dt);
      let onset = -1;
      {
        let sus = 0, cand = -1;
        for (let k = onsetSearch; k < takeoff; k++) {
          if (total[k] < onsetThresh) {
            if (sus === 0) cand = k;
            if (++sus >= ONSET_MIN) { onset = cand; break; }
          } else { sus = 0; cand = -1; }
        }
      }
      if (onset < 0) continue;

      // P0: Owen et al. (2014) — shift onset 30 ms earlier than the 5SD threshold crossing.
      // Rationale: by the time the 5×SD threshold trips, the actual force deviation has
      // already been building for ~30 ms (EMG-to-force delay). Aligns ttt, RSI-mod, and
      // phase durations with the academic standard (Owen 2014) and Hawkin Dynamics.
      // VALD ForceDecks does NOT use the 30 ms backshift, so this is user-toggleable to
      // match either convention.
      if (useOnsetBackshift) {
        onset = Math.max(firstOnPlate, onset - Math.round(0.030 / dt));
      }

      // landing may be null (recording ended mid-flight) — integrate to end of recording
      const intEnd = landing != null ? Math.min(n, landing + Math.round(1.0 / dt)) : n;
      const len  = intEnd - quietRef;
      const vel  = new Array(len).fill(0);
      const disp = new Array(len).fill(0);
      for (let k = 1; k < len; k++) {
        const ai = quietRef + k;
        const aN = forceCoreBodyweight?.accelerationFromForce
          ? forceCoreBodyweight.accelerationFromForce(total[ai - 1], localBW, localMass)
          : (total[ai - 1] - localBW) / localMass;
        const aC = forceCoreBodyweight?.accelerationFromForce
          ? forceCoreBodyweight.accelerationFromForce(total[ai], localBW, localMass)
          : (total[ai] - localBW) / localMass;
        vel[k]  = vel[k - 1] + (aN + aC) * 0.5 * dt;
        disp[k] = disp[k - 1] + (vel[k - 1] + vel[k]) * 0.5 * dt;
      }

      const velAt  = i => { const k = i - quietRef; return (k >= 0 && k < len) ? vel[k]  : 0; };
      const dispAt = i => { const k = i - quietRef; return (k >= 0 && k < len) ? disp[k] : 0; };

      let minVelIdx = onset;
      for (let k = onset + 1; k < takeoff; k++) {
        if (velAt(k) < velAt(minVelIdx)) minVelIdx = k;
      }

      // Real upward velocity zero-crossing (FORCE-SCIENCE M1): an actual negative→non-negative
      // sign flip AFTER a genuinely negative velocity minimum — never minVel defaulted as
      // zeroCross. A step-off / walk-off never satisfies this (velocity doesn't dip negative
      // then cross up), so it is rejected here instead of fabricating a contradictory metric
      // set. Integration drift alone (minVel still ≥ 0) also fails the guard.
      let zeroCrossIdx = null;
      for (let k = minVelIdx + 1; k < takeoff; k++) {
        if (velAt(k - 1) < 0 && velAt(k) >= 0) { zeroCrossIdx = k; break; }
      }
      if (zeroCrossIdx == null || !(Number.isFinite(velAt(minVelIdx)) && velAt(minVelIdx) < 0)) continue;

      let minForceIdx = onset;
      for (let k = onset + 1; k <= minVelIdx; k++) {
        if (total[k] < total[minForceIdx]) minForceIdx = k;
      }

      const phases = {
        onset, minForce: minForceIdx, minVel: minVelIdx,
        zeroCross: zeroCrossIdx, takeoff, landing,
      };

      // Validity gate (FORCE-SCIENCE M1): reject non-jump events (step-off, aborted attempts)
      // BEFORE computing metrics — an invalid candidate never gets a fabricated metric set.
      // Same caliber as computeMetrics: this trial's filtered total + localBW (== localMass*G)
      // + dt, concentric impulse over [zeroCross, takeoff]. Deterministic invariants ONLY —
      // no empirical jump-height/velocity thresholds (those await real-data calibration).
      let _propNetImpulse = 0;
      for (let i = zeroCrossIdx; i < takeoff; i++)
        _propNetImpulse += ((total[i] - localBW) + (total[i + 1] - localBW)) * 0.5 * dt;
      const _vTakeoff = velAt(takeoff);
      const _phaseOrderOK = onset <= minForceIdx && minForceIdx <= minVelIdx
        && minVelIdx <= zeroCrossIdx && zeroCrossIdx < takeoff;
      if (!(zeroCrossIdx < takeoff
            && Number.isFinite(_propNetImpulse) && _propNetImpulse > 0
            && Number.isFinite(_vTakeoff) && _vTakeoff > 0
            && _phaseOrderOK)) continue;

      // Pass localMass (self-consistent with localBW) so computeMetrics' internal
      // bw_n = localMass * G == localBW — keeps force/impulse/power normalisation consistent.
      const metrics = computeMetrics(time, total, left, right, velAt, dispAt, phases, localMass);
      const detection = {
        mode: detectionMode,
        rangeStartSec,
        rangeEndSec,
        quietRefSec: time[quietRef],
        takeoffSec: time[takeoff],
        landingSec: landing != null ? time[landing] : null,
        qualityFlags: [
          ...(bwFallback ? ['unstableBW'] : []),
          ...(quietRef > onset ? ['quietRefAfterOnset'] : []),
        ],
      };
      jumps.push({ index: jumps.length + 1, phases, metrics, vel, disp, quietRef, detection });
    }

    if (jumps.length === 0)
      throw new Error('No valid CMJ trials detected. Ensure the recording contains a countermovement jump with clear takeoff (force < 20 N).');

    return { jumps };
  }

  // ── 3. METRIC CALCULATOR ──────────────────────────────────────────────────

  function computeMetrics(time, total, left, right, velAt, dispAt, phases, mass_kg) {
    const { onset, minVel, zeroCross, takeoff, landing } = phases;
    const dt   = time[1] - time[0];
    const bw_n = forceCoreBodyweight?.bodyweightFromMass ? forceCoreBodyweight.bodyweightFromMass(mass_kg, G) : mass_kg * G;

    // ── Outcome ────────────────────────────────────────────────────────────
    const v_to = velAt(takeoff);  // kept for takeoff velocity display

    // JH from concentric net impulse (zeroCross → takeoff), avoids pre-jump drift
    let _conNetImp = 0;
    for (let i = zeroCross; i < takeoff; i++)
      _conNetImp += ((total[i] - bw_n) + (total[i + 1] - bw_n)) * 0.5 * dt;
    const v_con      = _conNetImp / mass_kg;
    const jumpHeight = (v_con > 0 ? v_con : 0) ** 2 / (2 * G);
    const ttt        = time[takeoff] - time[onset];
    const rsiMod     = jumpHeight / ttt;

    let flightTime = null, jumpHeightFT = null;
    if (landing !== null) {
      flightTime   = time[landing] - time[takeoff];
      jumpHeightFT = G * flightTime * flightTime / 8;
    }

    // Continuous flight-time vs impulse jump-height agreement (FORCE-SCIENCE M1): raw
    // quantities saved for future distribution calibration — NOT a fuzzy pass/fail flag, and
    // never used to reject a jump. Null when there is no landing (no flight-time JH to compare).
    let jumpHeightDeltaCm = null, jumpHeightDeltaPct = null;
    if (jumpHeightFT !== null && jumpHeight > 0) {
      jumpHeightDeltaCm  = (jumpHeightFT - jumpHeight) * 100;
      jumpHeightDeltaPct = (jumpHeightFT - jumpHeight) / jumpHeight * 100;
    }

    // ── CM Depth (metres) ──────────────────────────────────────────────────
    const dispAtOnset = dispAt(onset);
    let minDisp = dispAtOnset;
    for (let i = onset + 1; i <= zeroCross; i++) { const d = dispAt(i); if (d < minDisp) minDisp = d; }
    const cmDepth = Math.abs(dispAtOnset - minDisp);

    // ── Phase durations ────────────────────────────────────────────────────
    const unweightingTime = time[minVel]    - time[onset];
    const brakingTime     = time[zeroCross] - time[minVel];
    const propulsiveTime  = time[takeoff]   - time[zeroCross];
    const ttoPct = ttt > 0 ? {
      unweight: unweightingTime / ttt * 100,
      braking:  brakingTime     / ttt * 100,
      prop:     propulsiveTime  / ttt * 100,
    } : { unweight: 0, braking: 0, prop: 0 };

    // ── Helpers ────────────────────────────────────────────────────────────
    const avgR     = (arr, a, b) => { let s = 0; for (let i = a; i <= b; i++) s += arr[i]; return s / (b - a + 1); };
    const maxR     = (arr, a, b) => { let m = arr[a]; for (let i = a + 1; i <= b; i++) if (arr[i] > m) m = arr[i]; return m; };
    const minR     = (arr, a, b) => { let m = arr[a]; for (let i = a + 1; i <= b; i++) if (arr[i] < m) m = arr[i]; return m; };
    const trapz    = (arr, a, b) => { let s = 0; for (let i = a; i < b; i++) s += (arr[i] + arr[i+1]) * 0.5 * dt; return s; };
    const trapzNet = (arr, a, b) => {
      let s = 0;
      for (let i = a; i < b; i++) {
        const f0 = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(arr[i], bw_n) : arr[i] - bw_n;
        const f1 = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(arr[i + 1], bw_n) : arr[i + 1] - bw_n;
        s += (f0 + f1) * 0.5 * dt;
      }
      return s;
    };

    // ── Unweighting forces ─────────────────────────────────────────────────
    const avgUnweightForce  = avgR(total, onset, minVel);
    const peakUnweightForce = minR(total, onset, minVel); // minimum GRF = peak unloading

    // ── Braking forces ─────────────────────────────────────────────────────
    const avgBrakingForce  = avgR(total, minVel, zeroCross);
    const peakBrakingForce = maxR(total, minVel, zeroCross);

    // ── Propulsive forces ──────────────────────────────────────────────────
    const avgPropForce  = avgR(total, zeroCross, takeoff);
    const peakPropForce = maxR(total, zeroCross, takeoff);

    // ── Relative forces (%BW) ──────────────────────────────────────────────
    const relAvgUnweightForce  = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(avgUnweightForce, bw_n) : avgUnweightForce  / bw_n * 100;
    const relPeakUnweightForce = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(peakUnweightForce, bw_n) : peakUnweightForce / bw_n * 100;
    const relAvgBrakingForce   = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(avgBrakingForce, bw_n) : avgBrakingForce   / bw_n * 100;
    const relPeakBrakingForce  = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(peakBrakingForce, bw_n) : peakBrakingForce  / bw_n * 100;
    const relAvgPropForce      = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(avgPropForce, bw_n) : avgPropForce      / bw_n * 100;
    const relPeakPropForce     = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(peakPropForce, bw_n) : peakPropForce     / bw_n * 100;

    // ── RFD (N/s) — braking phase slope ────────────────────────────────────
    // Use 5-point (±2 sample) averages at each endpoint to reduce single-sample noise
    const avgAround = (arr, idx, w) => {
      const a = Math.max(0, idx - w), b = Math.min(arr.length - 1, idx + w);
      let s = 0; for (let k = a; k <= b; k++) s += arr[k]; return s / (b - a + 1);
    };
    const brakingRFD = brakingTime > 0
      ? (avgAround(total, zeroCross, 2) - avgAround(total, minVel, 2)) / brakingTime
      : 0;

    // ── RFD at fixed intervals from zeroCross (propulsive onset) ───────────
    const F_zc   = avgAround(total, zeroCross, 2);   // 5-pt average at zero-crossing
    const rfdInt = ms => {
      const idx = zeroCross + Math.round(ms / 1000 / dt);
      if (idx >= takeoff || idx >= total.length) return null;
      return (avgAround(total, idx, 2) - F_zc) / (ms / 1000);  // 5-pt average at target
    };
    const rfd50  = rfdInt(50);
    const rfd100 = rfdInt(100);
    const rfd150 = rfdInt(150);
    const rfd200 = rfdInt(200);
    const rfd250 = rfdInt(250);

    // ── Impulse ────────────────────────────────────────────────────────────
    const unweightingImpulse    = trapz(total, onset, minVel);
    const unweightNetImpulse    = trapzNet(total, onset, minVel);
    const brakingImpulse        = trapz(total, minVel, zeroCross);
    const propImpulse           = trapz(total, zeroCross, takeoff);
    const brakingNetImpulse     = trapzNet(total, minVel, zeroCross);
    const propNetImpulse        = trapzNet(total, zeroCross, takeoff);
    const impulseRatio          = brakingNetImpulse !== 0 ? propNetImpulse / brakingNetImpulse : null;

    const relUnweightNetImpulse = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(unweightNetImpulse, mass_kg) : unweightNetImpulse / mass_kg;
    const relBrakingNetImpulse  = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(brakingNetImpulse, mass_kg) : brakingNetImpulse  / mass_kg;
    const relPropNetImpulse     = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(propNetImpulse, mass_kg) : propNetImpulse     / mass_kg;

    // Concentric impulse split (P1 / P2)
    const propMid   = Math.round((zeroCross + takeoff) / 2);
    const p1Impulse = trapz(total, zeroCross, propMid);
    const p2Impulse = trapz(total, propMid,   takeoff);
    const p1p2Ratio = p2Impulse > 0 ? p1Impulse / p2Impulse : null;

    // ── Power ──────────────────────────────────────────────────────────────
    // peakBrakingPower is the most-negative (eccentric) power value;
    // initializing at 0 would mask all negative peaks → use -Infinity sentinel
    let peakPower = 0, peakBrakingPower = Infinity;
    let sumBrakPow = 0, brakN = 0, sumPropPow = 0, propN = 0;

    for (let i = minVel; i < zeroCross; i++) {
      const p = total[i] * velAt(i);
      sumBrakPow += p; brakN++;
      if (p < peakBrakingPower) peakBrakingPower = p;
    }
    if (!isFinite(peakBrakingPower)) peakBrakingPower = 0;
    for (let i = zeroCross; i < takeoff; i++) {
      const p = total[i] * velAt(i);
      sumPropPow += p; propN++;
      if (p > peakPower) peakPower = p;
    }
    const avgBrakingPower = brakN > 0 ? sumBrakPow / brakN : 0;
    const avgPropPower    = propN > 0 ? sumPropPow / propN : 0;

    const relPeakPower      = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(peakPower, mass_kg) : peakPower      / mass_kg;
    const relAvgPropPower   = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(avgPropPower, mass_kg) : avgPropPower   / mass_kg;
    const relPeakBrakPower  = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(peakBrakingPower, mass_kg) : peakBrakingPower / mass_kg;
    const relAvgBrakPower   = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(avgBrakingPower, mass_kg) : avgBrakingPower  / mass_kg;

    // ── Eccentric Peak Velocity (EPV) ─────────────────────────────────────
    // Maximum downward (negative) velocity during the braking phase [m/s, stored as positive]
    // VALD ForceDecks Technical Glossary V2.0 (2024); Gathercole et al. (2015)
    let epv = 0;
    for (let i = onset; i <= zeroCross; i++) {
      const v = velAt(i);
      if (v < -epv) epv = -v;
    }
    epv = epv > 0 ? epv : null;

    // ── Peak instantaneous RFD ────────────────────────────────────────────
    // max dF/dt using 10 ms rolling window across propulsive phase (Haff 2015)
    const WIN_CMJ_RFD = Math.max(2, Math.round(0.010 / dt));
    let peakRFD = 0;
    for (let k = zeroCross; k + WIN_CMJ_RFD <= takeoff; k++) {
      const r = (total[k + WIN_CMJ_RFD] - total[k]) / (WIN_CMJ_RFD * dt);
      if (r > peakRFD) peakRFD = r;
    }
    peakRFD = peakRFD > 0 ? peakRFD : null;

    // ── Peak acceleration ──────────────────────────────────────────────────
    let peakAcc = 0;
    for (let i = zeroCross; i < takeoff; i++) {
      const a = forceCoreBodyweight?.accelerationFromForce
        ? forceCoreBodyweight.accelerationFromForce(total[i], bw_n, mass_kg)
        : (total[i] - bw_n) / mass_kg;
      if (a > peakAcc) peakAcc = a;
    }

    // ── Dynamic Stiffness (N/m) ────────────────────────────────────────────
    // Peak propulsive force / countermovement depth
    // Guard against displacement-integration drift: physiological CM depth is
    // rarely > 0.5 m; anomaly → return null rather than a meaningless figure
    const stiffness = (cmDepth > 0 && cmDepth < 0.5) ? peakPropForce / cmDepth : null;

    // ── Spring-Like Correlation ────────────────────────────────────────────
    // Pearson(GRF, CM displacement) over countermovement window [onset→takeoff]
    const pearson = (xs, ys) => {
      const nn = xs.length;
      let mx = 0, my = 0;
      for (let i = 0; i < nn; i++) { mx += xs[i]; my += ys[i]; }
      mx /= nn; my /= nn;
      let num = 0, dx2 = 0, dy2 = 0;
      for (let i = 0; i < nn; i++) {
        const dx = xs[i] - mx, dy = ys[i] - my;
        num += dx * dy; dx2 += dx * dx; dy2 += dy * dy;
      }
      return Math.sqrt(dx2 * dy2) > 1e-12 ? num / Math.sqrt(dx2 * dy2) : 0;
    };
    const grfSlice = [], dispSlice = [];
    for (let i = onset; i <= takeoff; i++) { grfSlice.push(total[i]); dispSlice.push(dispAt(i)); }
    const springCorr = pearson(grfSlice, dispSlice);

    // ── L/R Force Asymmetry ────────────────────────────────────────────────
    const asymPct = (a, b) => {
      const l = avgR(left, a, b), r = avgR(right, a, b), tot = l + r;
      return Math.abs(tot) > 1 ? (l - r) / tot * 100 : 0;
    };
    const asymBraking = asymPct(minVel, zeroCross);
    const asymProp    = asymPct(zeroCross, takeoff);

    // ── L/R Impulse Asymmetry ──────────────────────────────────────────────
    const sideImp       = (arr, a, b) => { let s = 0; for (let i = a; i < b; i++) s += (arr[i]+arr[i+1])*0.5*dt; return s; };
    const lBrakImp      = sideImp(left,  minVel, zeroCross);
    const rBrakImp      = sideImp(right, minVel, zeroCross);
    const lPropImp      = sideImp(left,  zeroCross, takeoff);
    const rPropImp      = sideImp(right, zeroCross, takeoff);
    const asymBrakImpulse = (lBrakImp + rBrakImp) > 0.1 ? (lBrakImp - rBrakImp) / (lBrakImp + rBrakImp) * 100 : 0;
    const asymPropImpulse = (lPropImp + rPropImp) > 0.1 ? (lPropImp - rPropImp) / (lPropImp + rPropImp) * 100 : 0;

    // ── Time to Stabilization (TTS) ────────────────────────────────────────
    // First moment after landing when GRF stays within ±5%BW for a full second
    let timeToStab = null;
    if (landing !== null) {
      const stabThresh = 0.05 * bw_n;
      const stabWin    = Math.round(1.0 / dt);
      const ttsEnd     = Math.min(total.length - stabWin, landing + Math.round(5.0 / dt));
      for (let k = landing; k <= ttsEnd; k++) {
        let ok = true;
        for (let j = k; j < k + stabWin; j++) {
          if (Math.abs(total[j] - bw_n) > stabThresh) { ok = false; break; }
        }
        if (ok) { timeToStab = time[k] - time[landing]; break; }
      }
    }
    // Landing Performance Index = JH / TTS
    const landingPI = (timeToStab != null && timeToStab > 0) ? jumpHeight / timeToStab : null;

    const fmt  = (v, d = 1) => v != null ? +v.toFixed(d) : null;
    const fmtN = v => v != null ? (Math.abs(v) < 1e9 ? +v.toFixed(0) : null) : null;

    return {
      // Outcome
      jumpHeight:              fmt(jumpHeight * 100, 1),
      jumpHeightFT:            flightTime != null ? fmt(jumpHeightFT * 100, 1) : null,
      jumpHeightDeltaCm:       jumpHeightDeltaCm  != null ? fmt(jumpHeightDeltaCm, 1) : null,
      jumpHeightDeltaPct:      jumpHeightDeltaPct != null ? fmt(jumpHeightDeltaPct, 1) : null,
      takeoffVelocity:         fmt(v_to, 3),
      ttt:                     fmt(ttt, 3),
      rsiMod:                  fmt(rsiMod, 3),
      flightTime:              flightTime != null ? fmt(flightTime, 3) : null,
      cmDepth:                 fmt(cmDepth * 100, 1),
      peakAcc:                 fmt(peakAcc, 2),
      epv:                     epv != null ? fmt(epv, 3) : null,
      peakRFD:                 peakRFD != null ? fmtN(peakRFD) : null,

      // Phase durations
      unweightingTime:         fmt(unweightingTime, 3),
      brakingTime:             fmt(brakingTime, 3),
      propulsiveTime:          fmt(propulsiveTime, 3),
      ttoPct,

      // Force — Unweighting
      avgUnweightForce:        fmt(avgUnweightForce, 1),
      peakUnweightForce:       fmt(peakUnweightForce, 1),
      relAvgUnweightForce:     fmt(relAvgUnweightForce, 1),
      relPeakUnweightForce:    fmt(relPeakUnweightForce, 1),

      // Force — Braking
      avgBrakingForce:         fmt(avgBrakingForce, 1),
      peakBrakingForce:        fmt(peakBrakingForce, 1),
      relAvgBrakingForce:      fmt(relAvgBrakingForce, 1),
      relPeakBrakingForce:     fmt(relPeakBrakingForce, 1),
      brakingRFD:              fmtN(brakingRFD),

      // Force — Propulsive
      avgPropForce:            fmt(avgPropForce, 1),
      peakPropForce:           fmt(peakPropForce, 1),
      relAvgPropForce:         fmt(relAvgPropForce, 1),
      relPeakPropForce:        fmt(relPeakPropForce, 1),

      // RFD at fixed intervals from propulsive onset
      rfd50:                   rfd50  != null ? fmtN(rfd50)  : null,
      rfd100:                  rfd100 != null ? fmtN(rfd100) : null,
      rfd150:                  rfd150 != null ? fmtN(rfd150) : null,
      rfd200:                  rfd200 != null ? fmtN(rfd200) : null,
      rfd250:                  rfd250 != null ? fmtN(rfd250) : null,

      // Power
      peakPower:               fmtN(peakPower),
      avgPropPower:            fmtN(avgPropPower),
      peakBrakingPower:        fmtN(peakBrakingPower),
      avgBrakingPower:         fmtN(avgBrakingPower),
      relPeakPower:            fmt(relPeakPower, 1),
      relAvgPropPower:         fmt(relAvgPropPower, 1),
      relPeakBrakPower:        fmt(relPeakBrakPower, 1),
      relAvgBrakPower:         fmt(relAvgBrakPower, 1),

      // Impulse
      unweightingImpulse:      fmt(unweightingImpulse, 1),
      unweightNetImpulse:      fmt(unweightNetImpulse, 1),
      relUnweightNetImpulse:   fmt(relUnweightNetImpulse, 2),
      brakingImpulse:          fmt(brakingImpulse, 1),
      propImpulse:             fmt(propImpulse, 1),
      brakingNetImpulse:       fmt(brakingNetImpulse, 1),
      propNetImpulse:          fmt(propNetImpulse, 1),
      impulseRatio:            impulseRatio != null ? fmt(impulseRatio, 2) : null,
      relBrakingNetImpulse:    fmt(relBrakingNetImpulse, 2),
      relPropNetImpulse:       fmt(relPropNetImpulse, 2),
      p1Impulse:               fmt(p1Impulse, 1),
      p2Impulse:               fmt(p2Impulse, 1),
      p1p2Ratio:               p1p2Ratio != null ? fmt(p1p2Ratio, 2) : null,

      // Mechanics
      stiffness:               stiffness != null ? fmtN(stiffness) : null,
      springCorr:              fmt(springCorr, 3),

      // Asymmetry
      asymBraking,
      asymProp,
      asymBrakImpulse:         fmt(asymBrakImpulse, 1),
      asymPropImpulse:         fmt(asymPropImpulse, 1),

      // Landing
      timeToStab:              timeToStab != null ? fmt(timeToStab, 3) : null,
      landingPI:               landingPI != null ? fmt(landingPI, 3) : null,
    };
  }

  // ── 3b. CMJ CLASSIFICATION ───────────────────────────────────────────────
  // 4-type system: Unimodal/Bimodal × LF1/LF2
  // Bimodal criterion (McHugh 2020 adapted):
  //   (Fz1-Fz2)/Fz2 ≥ 2% AND (Fz3-Fz2)/Fz2 ≥ 2%
  // LF1 criterion: |F_peak - F_zeroCross| / F_peak ≤ 1%

  function smoothSignalSlice(arr, start, end, sampleRate, windowMs) {
    const halfWin = Math.round((windowMs / 2000) * sampleRate);
    const result = [];
    for (let i = start; i <= end; i++) {
      const lo = Math.max(start, i - halfWin);
      const hi = Math.min(end, i + halfWin);
      let sum = 0;
      for (let j = lo; j <= hi; j++) sum += arr[j];
      result.push(sum / (hi - lo + 1));
    }
    return result; // result[k] ↔ arr[start + k]
  }

  function classifyCMJJump(total, jump, sampleRate) {
    const { phases } = jump;
    if (!phases) return { type: '—', isBimodal: false, isLF1: false };
    const { zeroCross, takeoff } = phases;

    // 1. Smooth propulsive phase (±15ms moving average to suppress noise)
    const smoothed = smoothSignalSlice(total, zeroCross, takeoff, sampleRate, 15);

    // 2. Global peak in propulsive phase
    let peakLocalIdx = 0;
    for (let i = 1; i < smoothed.length; i++) {
      if (smoothed[i] > smoothed[peakLocalIdx]) peakLocalIdx = i;
    }
    const peakAbsIdx = zeroCross + peakLocalIdx;
    const peakVal    = smoothed[peakLocalIdx];

    // 3. Find local maxima (interior points only)
    const localMaxima = [];
    for (let i = 1; i < smoothed.length - 1; i++) {
      if (smoothed[i] > smoothed[i - 1] && smoothed[i] > smoothed[i + 1]) {
        localMaxima.push({ li: i, val: smoothed[i] });
      }
    }

    // 4. Bimodal detection — take top-2 peaks by height, check valley criterion
    let isBimodal = false;
    let P1li = null, P2li = null, P1val = null, P2val = null;
    let valleyLi = null, valleyVal = null;

    if (localMaxima.length >= 2) {
      const sorted = [...localMaxima].sort((a, b) => b.val - a.val).slice(0, 2);
      sorted.sort((a, b) => a.li - b.li); // ensure temporal order
      const [cand1, cand2] = sorted;

      // Find valley between the two candidates
      let minV = Infinity, minLi = cand1.li;
      for (let i = cand1.li; i <= cand2.li; i++) {
        if (smoothed[i] < minV) { minV = smoothed[i]; minLi = i; }
      }

      // (Fz1-valley)/valley ≥ 2% AND (Fz3-valley)/valley ≥ 2%
      // Guard: valley near 0 (or negative) would inflate the ratio to Infinity → require minV > 50 N
      if (minV > 50 && (cand1.val - minV) / minV >= 0.02 && (cand2.val - minV) / minV >= 0.02) {
        isBimodal = true;
        P1li = cand1.li; P1val = cand1.val;
        P2li = cand2.li; P2val = cand2.val;
        valleyLi = minLi; valleyVal = minV;
      }
    }

    // 5. LF1/LF2: is peak force essentially at zeroCross?
    // Guard: very small peakVal (no propulsive rise) makes the ratio unstable
    const fAtZeroCross = smoothed[0];
    const isLF1 = peakVal > 100 && Math.abs(peakVal - fAtZeroCross) / peakVal <= 0.01;

    // 6. 4-type result
    let type;
    if (!isBimodal && isLF1)       type = 'Ⅰ';
    else if (!isBimodal && !isLF1) type = 'Ⅱ';
    else if (isBimodal && isLF1)   type = 'Ⅲ';
    else                            type = 'Ⅳ';

    // 7. Bimodal derived metrics
    const domPeakVal = isBimodal ? Math.max(P1val, P2val) : null;
    const valleyDepthPct = (isBimodal && domPeakVal != null && valleyVal != null)
      ? (domPeakVal - valleyVal) / domPeakVal * 100
      : null;
    const peakIntervalMs = (isBimodal && P1li != null && P2li != null)
      ? (P2li - P1li) / sampleRate * 1000
      : null;

    return {
      type, isBimodal, isLF1,
      peakAbsIdx, peakVal,
      P1AbsIdx: isBimodal ? zeroCross + P1li : null,
      P2AbsIdx: isBimodal ? zeroCross + P2li : null,
      P1val, P2val,
      valleyAbsIdx: isBimodal ? zeroCross + valleyLi : null,
      valleyVal,
      valleyDepthPct,
      peakIntervalMs,
    };
  }

  // ── 4a. NORMALIZED CHART ──────────────────────────────────────────────────
  // Accepts multiple jumps, resamples each onto 0–100% time axis.
  // Key points a–g show hover tooltips with full descriptions.

  // Jump color palette — distinct enough to separate up to 5 trials.
  const NORM_COLORS = [
    { line: '#60a5fa', marker: '#60a5fa' },  // blue
    { line: '#fb923c', marker: '#fb923c' },  // orange
    { line: '#34d399', marker: '#34d399' },  // green
    { line: '#a78bfa', marker: '#a78bfa' },  // purple
    { line: '#f87171', marker: '#f87171' },  // red
  ];

  const KPOINT_DESCS = {
    a: 'Start of jump — movement onset',
    b: 'Peak negative force — maximum unloading',
    c: 'Peak negative velocity — end of unweighting phase',
    d: 'Peak force — maximum propulsive GRF',
    e: 'Peak negative position — lowest center of mass',
    f: 'Peak velocity — maximum upward velocity',
    g: 'Takeoff — feet leave the ground',
    PF1: 'Peak Force 1 — first propulsive peak',
    PF2: 'Peak Force 2 — second propulsive peak (bimodal)',
  };

  const CMJ_CLASSIFICATION_DISCLAIMER = '本分型描述推进期力曲线形态及峰值力出现时序。“力优/力不足”不代表绝对力量水平高低，也不应单独作为训练处方依据；解读时应结合相对峰值力、相对 RFD、冲量、跳跃高度及动作策略综合判断。';

  // Classification type descriptions. These describe curve shape and force timing;
  // they do not diagnose absolute strength or prescribe training.
  const CMJ_TYPE_INFO = {
    'Ⅰ': {
      label: '单峰 · 力优型',
      brief: '推进期呈单一主要力峰，峰值力出现在重心最低点附近。',
      mechanism: '说明运动员能在制动—推进转换早期较快地组织并输出力量，力—时序结构相对集中。',
    },
    'Ⅱ': {
      label: '单峰 · 力不足型',
      brief: '推进期呈单一主要力峰，但峰值力出现在重心最低点之后。',
      mechanism: '说明主要力量输出相对延迟，早期推进阶段的力表达可能不足，或发力时序偏晚。',
    },
    'Ⅲ': {
      label: '双峰 · 力优型',
      brief: '推进期呈两个可辨识力峰，主要峰值出现在重心最低点附近。',
      mechanism: '说明早期力量输出较充分，但推进过程中存在第二次力峰，可能反映分段发力、动作策略或关节贡献变化。',
    },
    'Ⅳ': {
      label: '双峰 · 力不足型',
      brief: '推进期呈两个可辨识力峰，主要峰值未出现在重心最低点附近。',
      mechanism: '说明早期推进力量输出相对不足或延迟，同时存在较明显的分段发力特征。',
    },
  };

  function CMJNormChart({ jumps, compareSelected, total, left, right, bw_n, mass_kg, overlays, classifications, showPhaseBg = true, phaseBasisLabel = null, hideHoverHint = false }) {
    const [tooltip, setTooltip] = useState(null);

    const W = 760, H = 320;
    const ML = 62, MR = 20, MT = 28, MB = 42;
    const PW = W - ML - MR, PH = H - MT - MB;
    const ov = overlays || {};
    const N = 600;

    const lerp    = (arr, t) => { const i0=Math.floor(t), i1=Math.min(i0+1,arr.length-1), f=t-i0; return arr[i0]*(1-f)+arr[i1]*f; };
    const lerpFn  = (fn, t, len) => { const i0=Math.floor(t), i1=Math.min(i0+1,len-1), f=t-i0; return fn(i0)*(1-f)+fn(i1)*f; };

    // Build per-jump resampled series
    const series = jumps
      .map((jump, origIdx) => ({ jump, origIdx }))
      .filter(({ origIdx }) => compareSelected.has(origIdx))
      .filter(({ jump }) => jump.phases !== null)
      .map(({ jump, origIdx }, ci) => {
        const { vel, disp, quietRef, phases } = jump;
        const { onset, minForce: mfIdx, minVel, zeroCross, takeoff } = phases;
        const tLen = total.length;
        const velAt  = i => { const k=i-quietRef; return (k>=0&&k<vel.length) ? vel[k] : 0; };
        const dispAt = i => { const k=i-quietRef; return (k>=0&&k<disp.length)? disp[k]: 0; };

        const nF=[],nV=[],nD=[],nA=[],nP=[];
        for (let i=0; i<N; i++) {
          const rt = onset + (takeoff-onset)*i/(N-1);
          const f = lerp(total,rt), v = lerpFn(velAt,rt,tLen);
          nF.push(f); nV.push(v);
          nD.push(lerpFn(dispAt,rt,tLen));
          nA.push((f-bw_n)/mass_kg); nP.push(f*v);
        }

        const toN = idx => (idx-onset)/(takeoff-onset)*100;
        let peakFIdx=minVel; for (let i=minVel;i<takeoff;i++) if (total[i]>total[peakFIdx]) peakFIdx=i;
        let peakVIdx=zeroCross; for (let i=zeroCross+1;i<takeoff;i++) if (velAt(i)>velAt(peakVIdx)) peakVIdx=i;

        const cl = classifications ? classifications[origIdx] : null;
        const pfPts = []; // extra peak markers beyond the a-g set
        if (cl && cl.isBimodal) {
          // For bimodal: mark BOTH peaks labeled PF1/PF2
          if (cl.P1AbsIdx != null) {
            const pct = toN(cl.P1AbsIdx);
            const f   = total[cl.P1AbsIdx];
            pfPts.push({ pct, f, label: 'PF1', col: 'rgba(52,211,153,.9)' });
          }
          if (cl.P2AbsIdx != null) {
            const pct = toN(cl.P2AbsIdx);
            const f   = total[cl.P2AbsIdx];
            pfPts.push({ pct, f, label: 'PF2', col: 'rgba(251,191,36,.9)' });
          }
        }

        return {
          origIdx, ci, color: NORM_COLORS[ci % NORM_COLORS.length], N,
          nF, nV, nD, nA, nP, pfPts,
          kpts: [
            { pct:0,            f:nF[0],             label:'a' },
            { pct:toN(mfIdx),   f:total[mfIdx],      label:'b' },
            { pct:toN(minVel),  f:total[minVel],     label:'c' },
            { pct:toN(peakFIdx),f:total[peakFIdx],   label:'d' },
            { pct:toN(zeroCross),f:total[zeroCross], label:'e' },
            { pct:toN(peakVIdx),f:total[peakVIdx],   label:'f' },
            { pct:100,          f:total[takeoff],     label:'g' },
          ],
          phases_norm: [
            { a:0,        b:toN(minVel),    lc:'rgba(99,179,237,.8)'  },
            { a:toN(minVel), b:toN(zeroCross), lc:'rgba(251,146,60,.8)' },
            { a:toN(zeroCross), b:100,        lc:'rgba(52,211,153,.8)' },
          ],
          label: `Jump ${jump.index}`,
          jh: jump.metrics.jumpHeight,
        };
      });

    if (series.length === 0) return (
      <div style={{ padding: '24px', color: 'var(--muted)', fontSize: 12, textAlign: 'center' }}>
        No jumps selected — pick at least one trial above.
      </div>
    );

    // Global force axis across all selected jumps
    let fMax = 0, fMin = 0;
    for (const s of series) for (let i=0;i<N;i++) { if (s.nF[i]>fMax) fMax=s.nF[i]; if (s.nF[i]<fMin) fMin=s.nF[i]; }
    fMax = Math.ceil(fMax/200)*200; fMin = Math.min(0, Math.floor(fMin/100)*100);

    // Global overlay extremes
    let vMin=0,vMax=0,dMin=0,dMax=0,aMin=0,aMax=0,pMin=0,pMax=0;
    for (const s of series) for (let i=0;i<N;i++) {
      if (ov.vel)  { if (s.nV[i]>vMax) vMax=s.nV[i]; if (s.nV[i]<vMin) vMin=s.nV[i]; }
      if (ov.disp) { if (s.nD[i]>dMax) dMax=s.nD[i]; if (s.nD[i]<dMin) dMin=s.nD[i]; }
      if (ov.acc)  { if (s.nA[i]>aMax) aMax=s.nA[i]; if (s.nA[i]<aMin) aMin=s.nA[i]; }
      if (ov.power){ if (s.nP[i]>pMax) pMax=s.nP[i]; if (s.nP[i]<pMin) pMin=s.nP[i]; }
    }

    const xS = pct => ML + pct/100*PW;
    const yF = f => MT + PH*(1-(f-fMin)/(fMax-fMin));
    const yBW = yF(bw_n);
    const spaceAbove = yBW-MT, spaceBelow = MT+PH-yBW;
    const bwScale = (hi,lo) => Math.min(hi>0?spaceAbove/hi:1e9, lo<0?spaceBelow/(-lo):1e9)*0.95;
    const clip = y => Math.max(MT,Math.min(MT+PH,y));
    const velSc=bwScale(vMax,vMin), dispSc=bwScale(dMax,dMin), accSc=bwScale(aMax,aMin), powSc=bwScale(pMax,pMin);
    const yR = v=>clip(yBW-v*velSc), yD=d=>clip(yBW-d*dispSc), yA=a=>clip(yBW-a*accSc), yP=p=>clip(yBW-p*powSc);

    const mkPath = arr => { let d=''; for (let i=0;i<N;i++) d+=(i===0?'M':'L')+xS(i/(N-1)*100).toFixed(1)+','+arr[i].toFixed(1); return d; };

    const fStep = fMax>1500?500:fMax>800?200:100;
    const fTicks=[]; for (let f=Math.ceil(fMin/fStep)*fStep;f<=fMax;f+=fStep) fTicks.push(f);
    const xTicks=[0,10,20,30,40,50,60,70,80,90,100];

    // Phase backgrounds from first series (reference)
    const ref = series[0];
    const phaseBgs = [
      { a:ref.phases_norm[0].a, b:ref.phases_norm[0].b, fill:'rgba(99,179,237,.13)',  bline:'rgba(99,179,237,.55)',  label:'Unweighting' },
      { a:ref.phases_norm[1].a, b:ref.phases_norm[1].b, fill:'rgba(251,146,60,.13)',  bline:'rgba(251,146,60,.55)',  label:'Braking'     },
      { a:ref.phases_norm[2].a, b:ref.phases_norm[2].b, fill:'rgba(52,211,153,.13)',  bline:'rgba(52,211,153,.55)',  label:'Propulsive'  },
    ];

    // Tooltip: measure approx text width at ~6.5px/char, clamp to chart
    const showTip = (label, svgX, svgY) => {
      const desc = KPOINT_DESCS[label] || label;
      const tw = desc.length * 6.2 + 12;
      const tx = Math.min(Math.max(svgX, ML + tw/2 + 4), ML + PW - tw/2 - 4);
      const above = svgY > MT + PH * 0.45;
      setTooltip({ desc, x: tx, y: above ? svgY - 14 : svgY + 26, tw });
    };

    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" />

        {/* Phase backgrounds + boundary lines */}
        {showPhaseBg && phaseBgs.map(({ a, b, fill, bline, label }) => {
          const xa=xS(a), xb=xS(b), w=xb-xa;
          return (
            <g key={label}>
              <rect x={xa.toFixed(1)} y={MT} width={w.toFixed(1)} height={PH} fill={fill} />
              {/* Left boundary line (skip 0%) */}
              {a > 0 && <line x1={xa.toFixed(1)} y1={MT} x2={xa.toFixed(1)} y2={MT+PH} stroke={bline} strokeWidth="1.2" />}
              {/* Right boundary line (skip 100%) */}
              {b < 100 && <line x1={xb.toFixed(1)} y1={MT} x2={xb.toFixed(1)} y2={MT+PH} stroke={bline} strokeWidth="1.2" />}
              {w > 30 && (
                <text x={((xa+xb)/2).toFixed(1)} y={MT+14} textAnchor="middle"
                  fontSize="10.5" fill={bline} fontWeight="600" letterSpacing=".03em">{label}</text>
              )}
            </g>
          );
        })}

        {/* BW reference line */}
        <line x1={ML} y1={yBW.toFixed(1)} x2={ML+PW} y2={yBW.toFixed(1)}
          stroke="rgba(15,23,42,.32)" strokeWidth="1" strokeDasharray="6 3" />
        <text x={ML+3} y={yBW-4} fontSize="8" fill="rgba(255,255,255,.42)">BW</text>

        {/* Overlay zero lines */}
        {ov.vel  &&vMin<0&&vMax>0&&<line x1={ML} y1={yR(0).toFixed(1)} x2={ML+PW} y2={yR(0).toFixed(1)} stroke="rgba(167,139,250,.22)" strokeWidth="1" strokeDasharray="3 3"/>}
        {ov.disp &&dMin<0&&dMax>0&&<line x1={ML} y1={yD(0).toFixed(1)} x2={ML+PW} y2={yD(0).toFixed(1)} stroke="rgba(52,211,153,.18)"  strokeWidth="1" strokeDasharray="3 3"/>}
        {ov.acc  &&aMin<0&&aMax>0&&<line x1={ML} y1={yA(0).toFixed(1)} x2={ML+PW} y2={yA(0).toFixed(1)} stroke="rgba(251,191,36,.18)"  strokeWidth="1" strokeDasharray="2 4"/>}
        {ov.power&&pMin<0&&pMax>0&&<line x1={ML} y1={yP(0).toFixed(1)} x2={ML+PW} y2={yP(0).toFixed(1)} stroke="rgba(248,113,113,.15)" strokeWidth="1" strokeDasharray="2 4"/>}

        {/* Per-jump curves + key points */}
        {series.map(s => {
          const { color, nF, nV, nD, nA, nP, kpts, ci } = s;
          const sw = series.length === 1 ? 1.8 : 1.5;
          return (
            <g key={s.origIdx}>
              {/* Overlay curves */}
              {ov.disp && <path d={mkPath(nD.map(yD))} fill="none" stroke={color.line} strokeWidth="1" strokeDasharray="4 3" strokeOpacity=".6"/>}
              {ov.acc  && <path d={mkPath(nA.map(yA))} fill="none" stroke={color.line} strokeWidth="1" strokeDasharray="2 2" strokeOpacity=".5"/>}
              {ov.power&& <path d={mkPath(nP.map(yP))} fill="none" stroke={color.line} strokeWidth="1" strokeDasharray="3 2" strokeOpacity=".5"/>}
              {ov.vel  && <path d={mkPath(nV.map(yR))} fill="none" stroke={color.line} strokeWidth="1" strokeDasharray="5 2" strokeOpacity=".7"/>}
              {/* Main force curve */}
              <path d={mkPath(nF.map(yF))} fill="none" stroke={color.line} strokeWidth={sw} strokeOpacity=".92" />
              {/* Key point markers */}
              {kpts.map(({ pct, f, label }) => {
                const cx=xS(pct), cy=yF(f);
                const above = cy > MT + PH*0.45;
                const tyo = above ? -12 : 16;
                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={series.length===1?6.5:5.5}
                      fill="var(--bg)" stroke={color.line} strokeWidth="2.2" />
                    <text x={cx.toFixed(1)} y={(cy+tyo).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>
                );
              })}
              {/* PF peak markers for bimodal */}
              {s.pfPts && s.pfPts.map(({ pct, f, label, col }) => {
                const cx = xS(pct), cy = yF(f);
                const above = cy > MT + PH * 0.45;
                return (
                  <g key={label} style={{ cursor: 'default' }}
                    onMouseEnter={() => showTip(label, cx, cy)}
                    onMouseLeave={() => setTooltip(null)}>
                    <rect x={(cx - 4).toFixed(1)} y={(cy - 4).toFixed(1)} width="8" height="8"
                      rx="2" fill="var(--bg)" stroke={col} strokeWidth="1.8" strokeOpacity=".9" />
                    <text x={cx.toFixed(1)} y={(cy + (above ? -9 : 13)).toFixed(1)}
                      textAnchor="middle" fontSize="8" fontWeight="700" fill={col} fillOpacity=".95">{label}</text>
                  </g>
                );
              })}
            </g>
          );
        })}

        {/* Hover tooltip */}
        {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>
        )}

        {/* Legend (multi-jump) */}
        {series.length > 1 && (
          <g transform={`translate(${ML+8},${MT+PH-14})`}>
            {series.map((s, i) => (
              <g key={s.origIdx} transform={`translate(${i*100},0)`}>
                <line x1="0" y1="5" x2="14" y2="5" stroke={s.color.line} strokeWidth="2" />
                <text x="17" y="9" fontSize="8" fill="var(--muted)">{s.label}  {s.jh}cm</text>
              </g>
            ))}
          </g>
        )}

        {/* Left Y-axis */}
        <line x1={ML} y1={MT} x2={ML} y2={MT+PH} stroke="rgba(15,23,42,.12)" />
        {fTicks.map(f => (
          <g key={f}>
            <line x1={ML-4} y1={yF(f).toFixed(1)} x2={ML} y2={yF(f).toFixed(1)} stroke="rgba(15,23,42,.22)" />
            <text x={ML-7} y={+yF(f).toFixed(1)+4} textAnchor="end" fontSize="11" fill="var(--muted)">{f}</text>
          </g>
        ))}
        <text x="13" y={MT+PH/2} textAnchor="middle" fontSize="11" fill="var(--muted)"
          transform={`rotate(-90,13,${MT+PH/2})`}>Force (N)</text>

        {/* Bottom X-axis */}
        <line x1={ML} y1={MT+PH} x2={ML+PW} y2={MT+PH} stroke="rgba(15,23,42,.12)" />
        {xTicks.map(p => (
          <g key={p}>
            <line x1={xS(p).toFixed(1)} y1={MT+PH} x2={xS(p).toFixed(1)} y2={MT+PH+4} stroke="rgba(15,23,42,.22)" />
            <text x={xS(p).toFixed(1)} y={MT+PH+14} textAnchor="middle" fontSize="11" fill="var(--muted)">{p}%</text>
          </g>
        ))}
        <text x={ML+PW/2} y={H-3} textAnchor="middle" fontSize="10.5" fill="var(--muted-2)">
          {hideHoverHint
            ? `Time (% of movement time)${showPhaseBg ? ` · 阶段背景以 ${phaseBasisLabel || ('Jump ' + (ref.origIdx+1))} 为基准` : ''}`
            : `Time (% of movement time) · hover a–g for descriptions · phase boundaries from Jump ${ref.origIdx+1}`}
        </text>
      </svg>
    );
  }

  // ── 4b. FORCE-DISPLACEMENT / FORCE-VELOCITY LOOP CHART ───────────────────
  // mode = 'fd' (x=displacement) | 'fv' (x=velocity). Y-axis always %BW.
  // Phase segments color-coded; direction arrows; a–g key points with tooltips;
  // shoelace area displayed. Supports multi-trial overlay via compareSelected.

  const PHASE_COLS = [
    'rgba(99,179,237,.9)',   // Unweighting — blue
    'rgba(251,146,60,.9)',   // Braking      — orange
    'rgba(52,211,153,.9)',   // Propulsive   — green
  ];
  const PHASE_NAMES = ['Unweighting', 'Braking', 'Propulsive'];

  function LoopChart({ mode, jumps, compareSelected, total, bw_n }) {
    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 per-jump series ────────────────────────────────────────────────
    const series = jumps
      .map((jump, origIdx) => ({ jump, origIdx }))
      .filter(({ origIdx }) => compareSelected.has(origIdx))
      .filter(({ jump }) => jump.phases !== null)
      .map(({ jump, origIdx }, ci) => {
        const { vel, disp, quietRef, phases } = jump;
        const { onset, minForce: mfIdx, minVel, zeroCross, takeoff } = phases;
        const velAt  = i => { const k = i - quietRef; return k >= 0 && k < vel.length  ? vel[k]  : 0; };
        const dispAt = i => { const k = i - quietRef; return k >= 0 && k < disp.length ? disp[k] : 0; };
        const getX   = isFD ? dispAt : velAt;

        // Build points
        const pts = [];
        for (let i = onset; i <= takeoff; i++) pts.push({ i, x: getX(i), y: total[i] / bw_n * 100 });

        // Key point indices
        let peakFIdx = minVel;
        for (let k = minVel; k < takeoff; k++) if (total[k] > total[peakFIdx]) peakFIdx = k;
        let peakVIdx = zeroCross;
        for (let k = zeroCross + 1; k < takeoff; k++) if (velAt(k) > velAt(peakVIdx)) peakVIdx = k;

        const kptIdxs = { a: onset, b: mfIdx, c: minVel, d: peakFIdx, e: zeroCross, f: peakVIdx, g: takeoff };
        const kpts = Object.entries(kptIdxs).map(([label, idx]) => ({
          label, x: getX(idx), y: total[idx] / bw_n * 100,
        }));

        // Shoelace area (closed loop: closes from takeoff back to onset)
        let area = 0;
        for (let k = onset; k < takeoff; k++)
          area += getX(k) * (total[k + 1] / bw_n) - getX(k + 1) * (total[k] / bw_n);
        // close the loop
        area += getX(takeoff) * (total[onset] / bw_n) - getX(onset) * (total[takeoff] / bw_n);
        // area in (m × unitless) × bw_n → J for FD (m·N), W for FV (m/s·N)
        const loopArea     = Math.abs(area) * 0.5 * bw_n;
        const loopAreaUnit = isFD ? 'J' : 'W';

        return {
          origIdx, ci,
          color: NORM_COLORS[ci % NORM_COLORS.length],
          pts, kpts, loopArea, loopAreaUnit,
          phaseRanges: [
            { from: onset,     to: minVel,    pci: 0 },
            { from: minVel,    to: zeroCross, pci: 1 },
            { from: zeroCross, to: takeoff,   pci: 2 },
          ],
          label: `Jump ${jump.index}`,
          jh: jump.metrics.jumpHeight,
        };
      });

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

    // ── Global axis ranges across all series ─────────────────────────────────
    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));

    // ── Helpers ───────────────────────────────────────────────────────────────
    const segPath = (pts, from, to) => {
      const sub = pts.filter(p => p.i >= from && p.i <= to);
      return sub.map((p, i) => (i === 0 ? 'M' : 'L') + xS(p.x).toFixed(1) + ',' + yS(p.y).toFixed(1)).join('');
    };

    // Arrow triangle at ~frac of a segment
    const arrow = (pts, from, to, frac) => {
      const sub = pts.filter(p => p.i >= from && p.i <= to);
      if (sub.length < 4) return null;
      const mi = Math.max(1, Math.min(sub.length - 2, Math.floor(sub.length * frac)));
      const p1 = sub[mi - 1], p2 = sub[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(sub[mi].x), cy = yS(sub[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`;
    };

    // Tooltip
    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
    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 isSingle   = series.length === 1;
    const arrowFrac  = (ci) => 0.35 + ci * 0.12;

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

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

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

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

        {/* 100% BW reference line */}
        <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: phase segments, arrows, key points */}
        {series.map(s => {
          const { pts, kpts, phaseRanges, origIdx, ci, color } = s;
          const sw = isSingle ? 2.2 : 1.7;
          return (
            <g key={origIdx}>
              {phaseRanges.map(({ from, to, pci }) => {
                const strokeCol = isSingle ? PHASE_COLS[pci] : color.line;
                const d   = segPath(pts, from, to);
                const arr = arrow(pts, from, to, arrowFrac(ci));
                return (
                  <g key={pci}>
                    <path d={d} fill="none" stroke={strokeCol} strokeWidth={sw} strokeOpacity=".9" />
                    {arr && <path d={arr} fill={strokeCol} fillOpacity=".8" stroke="none" />}
                  </g>
                );
              })}

              {kpts.map(({ label, x, y }) => {
                const cx = xS(x), cy = yS(y);
                const above = cy > MT + PH * 0.5;
                const markerCol = isSingle ? 'var(--text)' : color.line;
                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={isSingle ? 5.5 : 5}
                      fill="var(--bg)" stroke={markerCol} strokeWidth="2" />
                    <text x={cx.toFixed(1)} y={(cy + (above ? -11 : 14)).toFixed(1)}
                      textAnchor="middle" fontSize="12" fontWeight="700" fontStyle="italic"
                      fill={markerCol}
                      stroke="var(--bg)" strokeWidth="3.5" paintOrder="stroke" strokeLinejoin="round">{label}</text>
                  </g>
                );
              })}
            </g>
          );
        })}

        {/* Tooltip */}
        {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 */}
        <g transform={`translate(${ML}, ${MT + PH + 20})`}>
          {isSingle ? (
            // Phase legend
            PHASE_NAMES.map((name, i) => (
              <g key={name} transform={`translate(${i * 90}, 0)`}>
                <line x1="0" y1="5" x2="12" y2="5" stroke={PHASE_COLS[i]} strokeWidth="2" />
                <text x="15" y="9" fontSize="8" fill="var(--muted)">{name}</text>
              </g>
            ))
          ) : (
            // Jump legend + area
            series.map((s, i) => (
              <g key={s.origIdx} 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} · {s.loopArea.toFixed(0)}{s.loopAreaUnit}
                </text>
              </g>
            ))
          )}
        </g>

        {/* Area display (single series) */}
        {isSingle && series[0] && (
          <text x={ML + PW - 2} y={MT + 13} textAnchor="end" fontSize="10.5" fill="var(--muted-2)">
            Area: {series[0].loopArea.toFixed(1)} {series[0].loopAreaUnit}
          </text>
        )}

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

  // ── 4c. RAW FORCE-TIME CHART ──────────────────────────────────────────────

  function CMJChart({ time, left, right, total, velAt, dispAt, phases, bw_n, mass_kg, overlays, normalizeX, classResult, hideZoom = false }) {
    // ── Zoom state ─────────────────────────────────────────────────────────
    const [viewRange, setViewRange] = useState(null);  // null = full range
    const [zoomMode, setZoomMode]   = useState('xy');  // 'x' | 'y' | 'xy'
    const [dragging, setDragging]   = useState(null);
    const svgRef   = useRef(null);
    const stateRef = useRef({});  // mutable ref for wheel handler closure
    const clipId   = useRef('cmjc-' + Math.random().toString(36).slice(2, 7)).current;

    const W = 760, H = 310;
    const ML = 62, MR = 72, MT = 24, MB = 36;
    const PW = W - ML - MR, PH = H - MT - MB;
    const { onset, minVel, zeroCross, takeoff, landing } = phases;
    const dt = time[1] - time[0];
    const ov = overlays || { vel: true, disp: false, acc: false, power: false };

    const si = Math.max(0, onset - Math.round(0.5 / dt));
    const ei = landing != null
      ? Math.min(time.length - 1, landing + Math.round(0.5 / dt))
      : Math.min(time.length - 1, takeoff + Math.round(1.5 / dt));

    const t0 = time[si], t1 = time[ei];
    const tOnset = time[onset];
    const tTakeoff = time[takeoff];
    const tRange = tTakeoff - tOnset || 1;

    // Normalised mode is a true onset→takeoff viewport. The previous implementation
    // only changed tick labels while retaining the pre-onset / post-takeoff window,
    // which produced -70%…210% on a control labelled 0–100%.
    React.useEffect(() => { setViewRange(null); }, [normalizeX]);

    // ── Force axis (left) ──────────────────────────────────────────────────
    let fMax = 0, fMin = 0;
    for (let i = si; i <= ei; i++) {
      if (total[i] > fMax) fMax = total[i];
      if (left[i]  < fMin) fMin = left[i];
      if (right[i] < fMin) fMin = right[i];
    }
    fMax = Math.ceil(fMax / 200) * 200;
    fMin = Math.min(0, Math.floor(fMin / 100) * 100);

    // ── Overlay extremes ───────────────────────────────────────────────────
    let vMin = 0, vMax = 0, dMin = 0, dMax = 0, aMin = 0, aMax = 0, pMin = 0, pMax = 0;
    for (let i = si; i <= ei; i++) {
      const v = velAt(i);
      if (v > vMax) vMax = v; if (v < vMin) vMin = v;
      if (ov.disp && dispAt) { const d = dispAt(i); if (d > dMax) dMax = d; if (d < dMin) dMin = d; }
      if (ov.acc || ov.power) {
        const a = (total[i] - bw_n) / mass_kg;
        const p = total[i] * velAt(i);
        if (ov.acc)   { if (a > aMax) aMax = a; if (a < aMin) aMin = a; }
        if (ov.power) { if (p > pMax) pMax = p; if (p < pMin) pMin = p; }
      }
    }

    // ── View range (zoom) ──────────────────────────────────────────────────
    const baseT0 = normalizeX ? tOnset : t0;
    const baseT1 = normalizeX ? tTakeoff : t1;
    const vT0   = viewRange ? viewRange.t0   : baseT0;
    const vT1   = viewRange ? viewRange.t1   : baseT1;
    const vFMin = viewRange ? viewRange.fMin : fMin;
    const vFMax = viewRange ? viewRange.fMax : fMax;

    // Keep latest values accessible in the wheel-listener closure
    stateRef.current = { viewRange, zoomMode, t0: baseT0, t1: baseT1, fMin, fMax };

    const xS = t => ML + (t - vT0) / (vT1 - vT0) * PW;
    const yF = f => MT + PH * (1 - (f - vFMin) / (vFMax - vFMin));

    // ── BW-anchored overlay coordinate system ─────────────────────────────
    const yBW = yF(bw_n); // zoomed BW position (for rendering anchor)
    // Use BASE (unzoomed) BW position for overlay scales so Y-zoom doesn't rescale velocity
    const yBW_base   = MT + PH * (1 - (bw_n - fMin) / (fMax - fMin));
    const spaceAbove = yBW_base - MT;
    const spaceBelow = MT + PH - yBW_base;
    const bwScale = (posMax, negMin) => {
      const sUp   = posMax > 0 ? spaceAbove / posMax : 1e9;
      const sDown = negMin < 0 ? spaceBelow / (-negMin) : 1e9;
      return Math.min(sUp, sDown) * 0.95;
    };
    const velSc  = bwScale(vMax, vMin);
    const dispSc = bwScale(dMax, dMin);
    const accSc  = bwScale(aMax, aMin);
    const powSc  = bwScale(pMax, pMin);

    const clip = y => Math.max(MT, Math.min(MT + PH, y));
    const yR = v => clip(yBW - v  * velSc);
    const yD = d => clip(yBW - d  * dispSc);
    const yA = a => clip(yBW - a  * accSc);
    const yP = p => clip(yBW - p  * powSc);

    const rMin = -spaceBelow / velSc, rMax = spaceAbove / velSc;

    const stride = Math.max(1, Math.floor((ei - si) / 900));

    const makePath = fn => {
      let d = '', first = true;
      for (let i = si; i <= ei; i += stride) {
        const y = fn(i);
        if (!isFinite(y)) { first = true; continue; }
        d += (first ? 'M' : 'L') + xS(time[i]).toFixed(1) + ',' + y.toFixed(1);
        first = false;
      }
      return d;
    };

    const fStep = vFMax > 1500 ? 500 : vFMax > 800 ? 200 : 100;
    const fTicks = [];
    for (let f = Math.ceil(vFMin / fStep) * fStep; f <= vFMax; f += fStep) fTicks.push(f);

    const rStep  = Math.abs(rMax - rMin) > 8 ? 2 : 1;
    const rTicks = [];
    for (let v = Math.ceil(rMin); v <= Math.floor(rMax) + 0.01; v += rStep) rTicks.push(+v.toFixed(1));

    // ── X-axis ticks: absolute time or % normalized to onset→takeoff ────
    const xTicks = []; // { t: absolute time, label: string }
    if (normalizeX) {
      const pStart = Math.ceil((vT0 - tOnset) / tRange * 10) * 10;
      const pEnd   = Math.floor((vT1 - tOnset) / tRange * 10) * 10;
      for (let p = pStart; p <= pEnd + 0.1; p += 10) {
        xTicks.push({ t: tOnset + p / 100 * tRange, label: p + '%' });
      }
    } else {
      // Adaptive tick spacing based on visible duration
      const visDur = vT1 - vT0;
      const xStep = visDur < 0.15 ? 0.02 : visDur < 0.4 ? 0.05 : visDur < 1.0 ? 0.1 : visDur < 2.5 ? 0.2 : 0.5;
      const fmtT  = t => xStep < 0.05 ? (t * 1000).toFixed(0) + 'ms' : t.toFixed(2) + 's';
      for (let t = Math.ceil(vT0 / xStep) * xStep; t <= vT1 + xStep * 0.01; t += xStep) {
        xTicks.push({ t: +t.toFixed(4), label: fmtT(+t.toFixed(4)) });
      }
    }

    // ── Zoom interactions ──────────────────────────────────────────────────
    // Wheel: register non-passive listener to allow preventDefault
    React.useEffect(() => {
      if (hideZoom) return;  // report/print: no wheel-zoom hijack
      const svgEl = svgRef.current;
      if (!svgEl) return;
      const onWheel = (e) => {
        e.preventDefault();
        const rect = svgEl.getBoundingClientRect();
        const sx = (e.clientX - rect.left) / rect.width  * W;
        const sy = (e.clientY - rect.top)  / rect.height * H;
        if (sx < ML || sx > ML + PW || sy < MT || sy > MT + PH) return;

        const { viewRange: vr, zoomMode: zm, t0: bt0, t1: bt1, fMin: bfMin, fMax: bfMax } = stateRef.current;
        const curT0 = vr ? vr.t0 : bt0,   curT1 = vr ? vr.t1 : bt1;
        const curFMin = vr ? vr.fMin : bfMin, curFMax = vr ? vr.fMax : bfMax;

        // Smooth zoom: proportional to deltaY, clamped; 0.999^100 ≈ 0.90 (10% per notch)
        const delta  = Math.max(-300, Math.min(300, e.deltaY));
        const factor = Math.pow(0.999, delta);
        const cursorT = curT0 + (sx - ML) / PW * (curT1 - curT0);
        const cursorF = curFMin + (1 - (sy - MT) / PH) * (curFMax - curFMin);

        const nr = { t0: curT0, t1: curT1, fMin: curFMin, fMax: curFMax };
        if (zm === 'x' || zm === 'xy') {
          nr.t0 = cursorT - (cursorT - curT0) * factor;
          nr.t1 = cursorT + (curT1 - cursorT) * factor;
        }
        if (zm === 'y' || zm === 'xy') {
          nr.fMin = cursorF - (cursorF - curFMin) * factor;
          nr.fMax = cursorF + (curFMax - cursorF) * factor;
        }
        setViewRange(nr);
      };
      svgEl.addEventListener('wheel', onWheel, { passive: false });
      return () => svgEl.removeEventListener('wheel', onWheel);
    }, []); // registers once; reads latest state via stateRef

    // Reset zoom when jump changes
    React.useEffect(() => { setViewRange(null); }, [onset]);

    const handleMouseDown = (e) => {
      if (e.button !== 0) return;
      const rect = svgRef.current.getBoundingClientRect();
      const sx = (e.clientX - rect.left) / rect.width * W;
      const sy = (e.clientY - rect.top)  / rect.height * H;
      if (sx < ML || sx > ML + PW || sy < MT || sy > MT + PH) return;
      const { viewRange: vr, t0: bt0, t1: bt1, fMin: bfMin, fMax: bfMax } = stateRef.current;
      setDragging({
        startCX: e.clientX, startCY: e.clientY,
        startRange: vr ? { ...vr } : { t0: bt0, t1: bt1, fMin: bfMin, fMax: bfMax },
        rectW: rect.width, rectH: rect.height,
      });
      e.preventDefault();
    };

    const handleMouseMove = (e) => {
      if (!dragging) return;
      const { startCX, startCY, startRange, rectW, rectH } = dragging;
      const dxSvg = (e.clientX - startCX) / rectW * W;
      const dySvg = (e.clientY - startCY) / rectH * H;
      const dtPx  = (startRange.t1 - startRange.t0) / PW;
      const dfPx  = (startRange.fMax - startRange.fMin) / PH;
      setViewRange({
        t0:   startRange.t0   - dxSvg * dtPx,
        t1:   startRange.t1   - dxSvg * dtPx,
        fMin: startRange.fMin + dySvg * dfPx,
        fMax: startRange.fMax + dySvg * dfPx,
      });
    };

    const handleMouseUp = () => setDragging(null);

    const phaseRegions = [
      { a: onset,     b: minVel,    fill: 'rgba(99,179,237,.10)',  label: 'Unweighting', lc: 'rgba(99,179,237,.8)' },
      { a: minVel,    b: zeroCross, fill: 'rgba(251,146,60,.10)',  label: 'Braking',     lc: 'rgba(251,146,60,.8)' },
      { a: zeroCross, b: takeoff,   fill: 'rgba(52,211,153,.10)',  label: 'Propulsive',  lc: 'rgba(52,211,153,.8)' },
      ...(landing != null ? [{ a: takeoff, b: landing, fill: 'rgba(167,139,250,.10)', label: 'Flight', lc: 'rgba(167,139,250,.8)' }] : []),
    ];

    const fmtN = v => v >= 1000 ? (v/1000).toFixed(1)+'k' : v.toFixed(0);
    const legend = [
      { stroke: 'rgba(71,85,105,.85)',   sw: 2,   dash: '',    label: 'Total (N)' },
      { stroke: 'rgba(99,179,237,.5)',   sw: 1,   dash: '',    label: 'Left' },
      { stroke: 'rgba(251,146,60,.5)',   sw: 1,   dash: '',    label: 'Right' },
      ...(ov.vel  ? [{ stroke: 'rgba(167,139,250,.85)', sw: 1.5, dash: '5 2', label: `Vel  [${vMin.toFixed(1)}, ${vMax.toFixed(1)}] m/s` }] : []),
      ...(ov.disp ? [{ stroke: 'rgba(52,211,153,.75)',  sw: 1.4, dash: '4 3', label: `Disp [${dMin.toFixed(2)}, ${dMax.toFixed(2)}] m` }] : []),
      ...(ov.acc  ? [{ stroke: 'rgba(251,191,36,.7)',   sw: 1.2, dash: '2 2', label: `Acc  [${aMin.toFixed(1)}, ${aMax.toFixed(1)}] m/s²` }] : []),
      ...(ov.power? [{ stroke: 'rgba(248,113,113,.7)',  sw: 1.2, dash: '3 2', label: `Pwr  [${fmtN(pMin)}, ${fmtN(pMax)}] W` }] : []),
    ];
    let legX = 0;

    return (
      <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`}
        style={{ width: '100%', height: 'auto', display: 'block', overflow: 'visible',
          cursor: dragging ? 'grabbing' : (viewRange ? 'grab' : 'crosshair') }}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}>
        <defs>
          <clipPath id={clipId}>
            <rect x={ML} y={MT} width={PW} height={PH} />
          </clipPath>
        </defs>
        <rect width={W} height={H} fill="var(--panel)" rx="6" />

        {/* All data drawing is clipped to the chart area */}
        <g clipPath={`url(#${clipId})`}>

        {phaseRegions.map(({ a, b, fill, label, lc }) => {
          const xa = xS(time[Math.max(a, si)]);
          const xb = xS(time[Math.min(b, ei)]);
          const w  = Math.max(0, xb - xa);
          return (
            <g key={label}>
              <rect x={xa.toFixed(1)} y={MT} width={w.toFixed(1)} height={PH} fill={fill} />
              {w > 22 && <text x={((xa + xb) / 2).toFixed(1)} y={MT + 13} textAnchor="middle"
                fontSize="10.5" fill={lc} fontWeight="600" letterSpacing=".03em">{label}</text>}
            </g>
          );
        })}

        {[onset, minVel, zeroCross, takeoff, ...(landing != null ? [landing] : [])].map((idx, k) => (
          <line key={k} x1={xS(time[idx]).toFixed(1)} y1={MT} x2={xS(time[idx]).toFixed(1)} y2={MT + PH}
            stroke="rgba(15,23,42,.18)" strokeWidth="1" strokeDasharray="3 3" />
        ))}

        {/* BW reference line */}
        <line x1={ML} y1={yF(bw_n).toFixed(1)} x2={ML + PW} y2={yF(bw_n).toFixed(1)}
          stroke="rgba(15,23,42,.25)" strokeWidth="1" strokeDasharray="6 3" />
        <text x={ML + 3} y={+yF(bw_n).toFixed(1) - 4} fontSize="8" fill="rgba(15,23,42,.4)">BW</text>

        {/* Overlay zero-reference lines */}
        {ov.vel && rMin < 0 && rMax > 0 && (
          <line x1={ML} y1={yR(0).toFixed(1)} x2={ML + PW} y2={yR(0).toFixed(1)}
            stroke="rgba(167,139,250,.2)" strokeWidth="1" strokeDasharray="3 3" />
        )}
        {ov.disp && dispAt && dMin < 0 && dMax > 0 && (
          <line x1={ML} y1={yD(0).toFixed(1)} x2={ML + PW} y2={yD(0).toFixed(1)}
            stroke="rgba(52,211,153,.15)" strokeWidth="1" strokeDasharray="3 3" />
        )}
        {ov.acc && aMin < 0 && aMax > 0 && (
          <line x1={ML} y1={yA(0).toFixed(1)} x2={ML + PW} y2={yA(0).toFixed(1)}
            stroke="rgba(251,191,36,.15)" strokeWidth="1" strokeDasharray="2 4" />
        )}
        {ov.power && pMin < 0 && pMax > 0 && (
          <line x1={ML} y1={yP(0).toFixed(1)} x2={ML + PW} y2={yP(0).toFixed(1)}
            stroke="rgba(248,113,113,.12)" strokeWidth="1" strokeDasharray="2 4" />
        )}

        {/* Overlay curves (drawn before Force so Force stays on top) */}
        {ov.disp && dispAt && (
          <path d={makePath(i => yD(dispAt(i)))} fill="none"
            stroke="rgba(52,211,153,.75)" strokeWidth="1.4" strokeDasharray="4 3" />
        )}
        {ov.acc && (
          <path d={makePath(i => yA((total[i] - bw_n) / mass_kg))} fill="none"
            stroke="rgba(251,191,36,.7)" strokeWidth="1.2" strokeDasharray="2 2" />
        )}
        {ov.power && (
          <path d={makePath(i => yP(total[i] * velAt(i)))} fill="none"
            stroke="rgba(248,113,113,.7)" strokeWidth="1.2" strokeDasharray="3 2" />
        )}
        <path d={makePath(i => yF(left[i]))}  fill="none" stroke="rgba(99,179,237,.5)"   strokeWidth="1" />
        <path d={makePath(i => yF(right[i]))} fill="none" stroke="rgba(251,146,60,.5)"   strokeWidth="1" />
        <path d={makePath(i => yF(total[i]))} fill="none" stroke="rgba(71,85,105,.85)"   strokeWidth="2"   strokeLinejoin="round" />
        {ov.vel && (
          <path d={makePath(i => yR(velAt(i)))} fill="none"
            stroke="rgba(167,139,250,.85)" strokeWidth="1.5" strokeDasharray="5 2" />
        )}

        {/* PF Peak Force markers */}
        {classResult && (() => {
          const peaks = classResult.isBimodal
            ? [
                { absIdx: classResult.P1AbsIdx, label: 'PF1', col: 'rgba(52,211,153,.9)' },
                { absIdx: classResult.P2AbsIdx, label: 'PF2', col: 'rgba(251,191,36,.9)' },
              ]
            : [
                { absIdx: classResult.peakAbsIdx, label: 'PF1', col: 'rgba(52,211,153,.9)' },
              ];
          return peaks.map(({ absIdx, label, col }) => {
            if (absIdx == null || absIdx < si || absIdx > ei) return null;
            const xp = xS(time[absIdx]);
            const yp = yF(total[absIdx]);
            return (
              <g key={label}>
                <line x1={xp.toFixed(1)} y1={MT} x2={xp.toFixed(1)} y2={MT + PH}
                  stroke={col} strokeWidth="1.2" strokeDasharray="4 2" strokeOpacity=".75" />
                <text x={xp.toFixed(1)} y={MT + 10} textAnchor="middle" fontSize="8"
                  fontWeight="700" fill={col}>{label}</text>
                <polygon
                  points={`${xp.toFixed(1)},${(yp - 6).toFixed(1)} ${(xp - 4.5).toFixed(1)},${(yp - 14).toFixed(1)} ${(xp + 4.5).toFixed(1)},${(yp - 14).toFixed(1)}`}
                  fill={col} fillOpacity=".85" />
              </g>
            );
          });
        })()}

        {/* Valley marker for bimodal */}
        {classResult && classResult.isBimodal && classResult.valleyAbsIdx != null && (() => {
          const vi = classResult.valleyAbsIdx;
          if (vi < si || vi > ei) return null;
          const xv = xS(time[vi]);
          const yv = yF(total[vi]);
          return (
            <g>
              {/* downward triangle */}
              <polygon
                points={`${xv.toFixed(1)},${(yv + 8).toFixed(1)} ${(xv - 4).toFixed(1)},${(yv).toFixed(1)} ${(xv + 4).toFixed(1)},${(yv).toFixed(1)}`}
                fill="rgba(167,139,250,.75)" />
              <text x={xv.toFixed(1)} y={(yv + 19).toFixed(1)} textAnchor="middle"
                fontSize="7.5" fill="rgba(167,139,250,.7)" fontWeight="600">谷</text>
            </g>
          );
        })()}

        </g>{/* end clipPath group */}

        {/* ── Zoom controls (outside clip, top-right of chart area) ─────── */}
        {!hideZoom && [
          { id: 'xy', label: 'XY' },
          { id: 'x',  label: 'X轴' },
          { id: 'y',  label: 'Y轴' },
          { id: 'reset', label: '⟲' },
        ].map(({ id, label }, i) => {
          const bw = id === 'xy' ? 22 : id === 'reset' ? 20 : 28;
          const gap = 3;
          // Position from right, right-to-left order
          const offsets = [0, 25, 53, 85];
          const rx = ML + PW - offsets[i] - bw;
          const ry = MT + 3;
          const isActive = id !== 'reset' && zoomMode === id;
          return (
            <g key={id} onClick={() => id === 'reset' ? setViewRange(null) : setZoomMode(id)}
              style={{ cursor: 'pointer' }}>
              <rect x={rx} y={ry} width={bw} height={15} rx="3"
                fill={isActive ? 'rgba(99,179,237,.18)' : 'rgba(15,25,40,.8)'}
                stroke={isActive ? 'rgba(99,179,237,.55)' : 'rgba(255,255,255,.13)'}
                strokeWidth="0.9" />
              <text x={rx + bw / 2} y={ry + 10.5} textAnchor="middle" fontSize="8"
                fontWeight={isActive ? '700' : '400'}
                fill={isActive ? 'rgba(99,179,237,.95)' : (id === 'reset' && viewRange ? 'rgba(248,113,113,.85)' : 'rgba(255,255,255,.42)')}>{label}</text>
            </g>
          );
        })}

        {/* Left Y-axis — Force */}
        <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
        {fTicks.map(f => (
          <g key={f}>
            <line x1={ML - 4} y1={yF(f).toFixed(1)} x2={ML} y2={yF(f).toFixed(1)} stroke="rgba(15,23,42,.22)" />
            <text x={ML - 7} y={+yF(f).toFixed(1) + 4} textAnchor="end" fontSize="11" fill="var(--muted)">{f}</text>
          </g>
        ))}
        <text x={13} y={MT + PH / 2} textAnchor="middle" fontSize="11" fill="var(--muted)"
          transform={`rotate(-90,13,${MT + PH / 2})`}>Force (N)</text>

        {/* Right Y-axis — Velocity */}
        <line x1={ML + PW} y1={MT} x2={ML + PW} y2={MT + PH} stroke="rgba(167,139,250,.2)" />
        {rTicks.map(v => (
          <g key={v}>
            <line x1={ML + PW} y1={yR(v).toFixed(1)} x2={ML + PW + 4} y2={yR(v).toFixed(1)} stroke="rgba(167,139,250,.3)" />
            <text x={ML + PW + 7} y={+yR(v).toFixed(1) + 4} textAnchor="start" fontSize="11" fill="rgba(167,139,250,.8)">{v}</text>
          </g>
        ))}
        <text x={W - 8} y={MT + PH / 2} textAnchor="middle" fontSize="11" fill="rgba(167,139,250,.8)"
          transform={`rotate(90,${W - 8},${MT + PH / 2})`}>Vel (m/s)</text>

        {/* Bottom X-axis */}
        <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
        {xTicks.map(({ t, label }) => (
          <g key={label}>
            <line x1={xS(t).toFixed(1)} y1={MT + PH} x2={xS(t).toFixed(1)} y2={MT + PH + 4} stroke="rgba(15,23,42,.22)" />
            <text x={xS(t).toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="11" fill="var(--muted)">{label}</text>
          </g>
        ))}
        {normalizeX && (
          <text x={ML + PW / 2} y={H - 2} textAnchor="middle" fontSize="8" fill="var(--muted-2)">
            0% = onset · 100% = takeoff
          </text>
        )}

        {/* Legend */}
        <g transform={`translate(${ML + 8},${MT + PH - 16})`}>
          {legend.map(({ stroke, sw, dash, label }, idx) => {
            const ox = legX;
            legX += label.length * 5.5 + 28;
            return (
              <g key={idx} transform={`translate(${ox},0)`}>
                <line x1="0" y1="5" x2="14" y2="5" stroke={stroke} strokeWidth={sw} strokeDasharray={dash} />
                <text x="17" y="9" fontSize="8" fill="var(--muted)">{label}</text>
              </g>
            );
          })}
        </g>
      </svg>
    );
  }

  // ── 5. UI COMPONENTS ──────────────────────────────────────────────────────

  function MetricInfo({ formula, cite: cit }) {
    const [show, setShow] = React.useState(false);
    return (
      <span style={{ position: 'relative', display: 'inline-flex', marginLeft: 4, flexShrink: 0 }}
        onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
        <span style={{ width: 14, height: 14, borderRadius: '50%', border: '1px solid var(--border-strong)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 8.5, color: 'var(--muted-2)', cursor: 'default', lineHeight: 1, fontFamily: 'var(--font-sans)', userSelect: 'none' }}>i</span>
        {show && (
          <div style={{ position: 'absolute', bottom: 'calc(100% + 6px)', left: '50%', transform: 'translateX(-50%)', zIndex: 1000, background: 'var(--panel)', border: '1px solid var(--border-strong)', borderRadius: 7, padding: '8px 10px', minWidth: 190, maxWidth: 280, boxShadow: '0 6px 20px rgba(0,0,0,.14)', pointerEvents: 'none', whiteSpace: 'normal' }}>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--text)', lineHeight: 1.6, marginBottom: cit ? 5 : 0 }}>{formula}</div>
            {cit && <div style={{ fontSize: 9, color: 'var(--muted)', lineHeight: 1.5, borderTop: '1px solid var(--border)', paddingTop: 4 }}>{cit}</div>}
          </div>
        )}
      </span>
    );
  }

  // MCard — 优先以中文 label 为主标题（若提供 `cn`），英文 label 作为副标题
  function MCard({ label, value, unit, sub, accent, cn, info }) {
    return (
      <div style={{
        background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8,
        padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 2,
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            {cn ? (
              <>
                <div style={{ fontSize: 11.5, color: 'var(--text-2)', fontWeight: 500, lineHeight: 1.2 }}>{cn}</div>
                <div style={{ fontSize: 9, color: 'var(--muted)', letterSpacing: '.04em', textTransform: 'uppercase', lineHeight: 1.2 }}>{label}</div>
              </>
            ) : (
              <div style={{ fontSize: 10, color: 'var(--muted)', letterSpacing: '.04em', textTransform: 'uppercase' }}>{label}</div>
            )}
          </div>
          {info && <MetricInfo formula={info.formula} cite={info.cite} />}
        </div>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 2 }}>
          <span className="mono" style={{ fontSize: 19, fontWeight: 600, color: accent || 'var(--text)' }}>
            {value ?? '—'}
          </span>
          {unit && <span style={{ fontSize: 11, color: 'var(--muted)' }}>{unit}</span>}
        </div>
        {sub && <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 1 }}>{sub}</div>}
      </div>
    );
  }

  // MCardSm — 中文主、英文副（紧凑版）
  function MCardSm({ label, value, unit, accent, cn, info }) {
    return (
      <div style={{
        background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6,
        padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 1,
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
          <div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
            {cn ? (
              <>
                <div style={{ fontSize: 10.5, color: 'var(--text-2)', fontWeight: 500, lineHeight: 1.15, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cn}</div>
                <div style={{ fontSize: 8.5, color: 'var(--muted)', letterSpacing: '.03em', textTransform: 'uppercase', lineHeight: 1.15, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</div>
              </>
            ) : (
              <div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.04em', textTransform: 'uppercase' }}>{label}</div>
            )}
          </div>
          {info && <MetricInfo formula={info.formula} cite={info.cite} />}
        </div>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 1 }}>
          <span className="mono" style={{ fontSize: 15, fontWeight: 600, color: accent || 'var(--text)' }}>
            {value ?? '—'}
          </span>
          {unit && <span style={{ fontSize: 10, color: 'var(--muted)' }}>{unit}</span>}
        </div>
      </div>
    );
  }

  function PhaseBar({ label, pct, color, cn }) {
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11 }}>
        <span style={{ width: 150, flexShrink: 0, display: 'flex', flexDirection: 'column', lineHeight: 1.15 }}>
          {cn && <span style={{ color: 'var(--text-2)', fontSize: 11 }}>{cn}</span>}
          <span style={{ color: 'var(--muted)', fontSize: cn ? 9 : 11, textTransform: cn ? 'uppercase' : 'none', letterSpacing: cn ? '.03em' : 0 }}>{label}</span>
        </span>
        <div style={{ flex: 1, height: 5, background: 'var(--panel-hi)', borderRadius: 3, overflow: 'hidden' }}>
          <div style={{ width: pct + '%', height: '100%', background: color, borderRadius: 3 }} />
        </div>
        <span className="mono" style={{ color: 'var(--text-2)', width: 34, textAlign: 'right', fontSize: 11 }}>
          {pct.toFixed(0)}%
        </span>
      </div>
    );
  }

  function AsymBar({ label, value, cn }) {
    const abs     = Math.abs(value);
    const leftDom = value > 0;
    // Severity levels — paired with a glyph so colour-blind users get a
    // non-colour cue: ● (ok) / ◐ (warning) / ⚠ (alert)
    const severity = abs > 15 ? 'alert' : abs > 10 ? 'warn' : 'ok';
    const color    = severity === 'alert' ? 'var(--neg)' : severity === 'warn' ? 'var(--warn)' : 'var(--pos)';
    const glyph    = severity === 'alert' ? '⚠' : severity === 'warn' ? '◐' : '●';
    const barW     = Math.min(abs * 1.5, 48);
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11 }}>
        <span style={{ width: 150, flexShrink: 0, display: 'flex', flexDirection: 'column', lineHeight: 1.15 }}>
          {cn && <span style={{ color: 'var(--text-2)', fontSize: 11 }}>{cn}</span>}
          <span style={{ color: 'var(--muted)', fontSize: cn ? 9 : 11, textTransform: cn ? 'uppercase' : 'none', letterSpacing: cn ? '.03em' : 0 }}>{label}</span>
        </span>
        <div style={{ flex: 1, height: 5, background: 'var(--panel-hi)', borderRadius: 3, position: 'relative', overflow: 'visible' }}>
          <div style={{
            position: 'absolute', top: 0, height: '100%',
            left: leftDom ? `calc(50% - ${barW}px)` : '50%',
            width: barW + 'px', background: color, borderRadius: 3,
            // Diagonal stripe overlay for "alert" so the severity reads even in monochrome
            backgroundImage: severity === 'alert'
              ? 'repeating-linear-gradient(45deg, rgba(0,0,0,0) 0 3px, rgba(0,0,0,.25) 3px 5px)'
              : 'none',
          }} />
          <div style={{ position: 'absolute', left: '50%', top: -3, width: 1, height: 11, background: 'var(--border-strong)' }} />
        </div>
        <span className="mono" style={{ color, width: 64, textAlign: 'right', fontSize: 11, display: 'inline-flex', alignItems: 'center', justifyContent: 'flex-end', gap: 3 }}>
          <span title={severity === 'alert' ? 'Asymmetry alert (>15%)' : severity === 'warn' ? 'Warning (>10%)' : 'Within range'} style={{ fontSize: 9, opacity: .85 }}>{glyph}</span>
          {leftDom ? 'L' : 'R'} {abs.toFixed(1)}%
        </span>
      </div>
    );
  }

  // ── 6. JUMP SELECTOR ──────────────────────────────────────────────────────

  function JumpSelector({ jumps, selectedIdx, onSelect, classifications, representativeIdx }) {
    if (jumps.length <= 1) return null;
    return (
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        {jumps.map((j, idx) => {
          const active = idx === selectedIdx;
          const representative = idx === representativeIdx;
          const cl = classifications ? classifications[idx] : null;
          return (
            <button key={idx} onClick={() => onSelect(idx)} style={{
              display: 'flex', flexDirection: 'column', alignItems: 'center',
              padding: '8px 16px', borderRadius: 8, cursor: 'pointer',
              background: active ? 'var(--accent-soft)' : 'var(--panel-2)',
              border: `1px solid ${active ? 'rgba(59,130,246,.4)' : 'var(--border)'}`,
              color: 'inherit', font: 'inherit', gap: 2,
            }}>
              <span style={{ fontSize: 11, color: active ? 'var(--accent-2)' : 'var(--muted)', fontWeight: 600, letterSpacing: '.05em', textTransform: 'uppercase' }}>
                Jump {j.index}
              </span>
              <span className="mono" style={{ fontSize: 15, fontWeight: 700, color: active ? 'var(--pos)' : 'var(--text-2)' }}>
                {j.metrics.jumpHeight} cm
              </span>
              <span className="mono" style={{ fontSize: 10, color: 'var(--muted)' }}>
                RSI {j.metrics.rsiMod}
              </span>
              {cl && (
                <span style={{
                  marginTop: 2, fontSize: 9.5, fontWeight: 700,
                  color: cl.isLF1 ? 'rgba(52,211,153,.9)' : 'rgba(251,191,36,.85)',
                  letterSpacing: '.03em',
                }}>Type {cl.type}</span>
              )}
              {representative ? (
                <span style={{ marginTop: 3, fontSize: 9.5, fontWeight: 700, color: 'var(--text)' }}>✓ 代表 Trial</span>
              ) : (
                <span style={{ marginTop: 3, fontSize: 9.5, color: 'var(--muted)' }}>设为代表 Trial</span>
              )}
            </button>
          );
        })}
      </div>
    );
  }

  // ── 6b. REPORT GENERATOR ─────────────────────────────────────────────────

  function generateReport(meta, jumps, classifications, time, total) {
    const esc = s => String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
    const fmt = (v, d) => (v == null || !isFinite(v)) ? '—' : (d != null ? Number(v).toFixed(d) : v);
    const dateStr = meta.date ? (() => { try { return new Date(meta.date).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); } catch { return meta.date; } })() : '—';
    const gen = (() => { try { return new Date().toLocaleDateString('zh-CN'); } catch { return dateStr; } })();
    const org = meta.org || meta.institution || 'Sports Science OS';
    const athleteName = meta.athleteName || meta.athleteId || '未命名运动员';
    const initials = (String(athleteName).trim().slice(0, 2) || '—').toUpperCase();

    // ── A类力学曲线忠实呈现豁免（FL0 §2）：报告曲线直接取原始 total 采样窗口，
    //    逐点 M/L 折线，无平滑 / 无重采样。相位边界忠实标注。──
    function faithfulCurve(jump) {
      if (!jump || !jump.phases || !Array.isArray(total)) return null;
      const { onset, minForce, minVel, zeroCross, takeoff } = jump.phases;
      if (!(takeoff > onset)) return null;
      const lo = Math.max(0, onset), hi = Math.min(total.length - 1, takeoff);
      let fMax = -Infinity, fMin = Infinity;
      for (let i = lo; i <= hi; i++) { const f = total[i]; if (f > fMax) fMax = f; if (f < fMin) fMin = f; }
      if (!isFinite(fMax) || !isFinite(fMin) || fMax <= fMin) return null;
      const VBW = 560, VBH = 110, PAD = 6, span = hi - lo;
      const xOf = i => PAD + (i - lo) / span * (VBW - 2 * PAD);
      const yOf = f => PAD + (1 - (f - fMin) / (fMax - fMin)) * (VBH - 2 * PAD);
      // 逐点忠实折线（无平滑）
      let d = '';
      for (let i = lo; i <= hi; i++) d += `${i === lo ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(total[i]).toFixed(1)} `;
      const marker = (idx, label, color) => (idx != null && idx >= lo && idx <= hi)
        ? `<line x1="${xOf(idx).toFixed(1)}" y1="${PAD}" x2="${xOf(idx).toFixed(1)}" y2="${VBH - PAD}" stroke="${color}" stroke-width="0.8" stroke-dasharray="3 3" opacity="0.6"/>`
        : '';
      const phases = marker(minForce, 'minF', '#6b7287')
        + marker(minVel, 'minV', '#6b7287')
        + marker(zeroCross, 'zc', '#2e6b3d');
      return `<svg viewBox="0 0 ${VBW} ${VBH}" xmlns="http://www.w3.org/2000/svg">`
        + phases
        + `<path d="${d.trim()}" fill="none" stroke="#1c2433" stroke-width="1.4"/>`
        + `</svg>`;
    }

    const repJump = jumps[0];
    const rm = repJump ? repJump.metrics : {};
    // p-kpis 核心指标（现有真值；无基线 delta 数据则不显示 chip，不虚构）。
    const kpiCells = [
      { l: 'Jump Height', v: fmt(rm.jumpHeight), u: 'cm' },
      { l: 'mRSI', v: fmt(rm.rsiMod), u: '' },
      { l: 'Peak Power', v: fmt(rm.peakPower), u: 'W' },
      { l: 'Time to Takeoff', v: fmt(rm.ttt), u: 's' },
    ].map(k => `<div class="p-kpi"><div class="l">${esc(k.l)}</div><div class="v">${esc(k.v)}${k.u ? '<small> ' + k.u + '</small>' : ''}</div></div>`).join('');

    const jumpSections = jumps.map((j, idx) => {
      const m = j.metrics;
      const cl = classifications ? classifications[idx] : null;
      const info = cl ? (CMJ_TYPE_INFO[cl.type] || {}) : {};
      const svg = faithfulCurve(j);
      const bimodalExtra = (cl && cl.isBimodal)
        ? `<p class="p-note">谷深 ${cl.valleyDepthPct != null ? cl.valleyDepthPct.toFixed(1) + '%' : '—'} · 峰间距 ${cl.peakIntervalMs != null ? cl.peakIntervalMs.toFixed(0) + ' ms' : '—'}</p>`
        : '';
      const sec = (label, n) => `<div class="p-sec"><b>${esc(label)}</b><div class="ln"></div><span class="n">${esc(n)}</span></div>`;
      return sec(`Jump ${j.index}` + (cl ? ' · Type ' + cl.type + ' · ' + (info.label || '') : ''), cl ? (cl.isLF1 ? 'LF1' : 'LF2') : 'trial')
        + (cl && info.brief ? `<p class="p-note">${esc(info.brief)}</p>` : '')
        + (svg
          ? `<div class="p-chart"><div class="t">Force-Time <small>忠实原始采样 · 无平滑</small></div>${svg}`
            + `<p class="p-note">相位标注: — minForce · minVel · zeroCross（推进起点）｜ 卸载 ${m.unweightingTime}s (${m.ttoPct.unweight.toFixed(0)}%) · 制动 ${m.brakingTime}s (${m.ttoPct.braking.toFixed(0)}%) · 推进 ${m.propulsiveTime}s (${m.ttoPct.prop.toFixed(0)}%)</p></div>`
          : '')
        + bimodalExtra
        + `<table class="p-metrics">
          <thead><tr><th>指标</th><th>数值</th><th>单位</th></tr></thead>
          <tbody>
            <tr><td>Jump Height</td><td>${fmt(m.jumpHeight)}</td><td>cm</td></tr>
            <tr><td>RSI-mod</td><td>${fmt(m.rsiMod)}</td><td></td></tr>
            <tr><td>Time to Takeoff</td><td>${fmt(m.ttt)}</td><td>s</td></tr>
            <tr><td>Peak Prop Force</td><td>${fmt(m.peakPropForce)}</td><td>N</td></tr>
            <tr><td>Peak Power</td><td>${fmt(m.peakPower)}</td><td>W</td></tr>
            <tr><td>Impulse Ratio</td><td>${fmt(m.impulseRatio)}</td><td></td></tr>
            <tr><td>CM Depth</td><td>${fmt(m.cmDepth)}</td><td>cm</td></tr>
            <tr><td>Stiffness</td><td>${fmt(m.stiffness)}</td><td>N/m</td></tr>
          </tbody>
        </table>`;
    }).join('');

    const html = `<!DOCTYPE html><html lang="zh"><head>
<meta charset="utf-8">
<title>CMJ Force Report — ${esc(athleteName)} ${dateStr}</title>
<style>
  :root {
    --paper:#ffffff; --paper-ink:#1c2433; --paper-muted:#6b7287; --paper-line:#e6e7ea;
    --pale-green:#e9f4ec; --pale-green-ink:#2e6b3d; --paper-warm:#c2410c;
    --mono:"SF Mono",ui-monospace,Menlo,monospace;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px;
    color: var(--paper-ink); background: var(--paper); padding: 28px 34px; }
  .p-band { display:flex; align-items:baseline; gap:14px; border-bottom:2px solid var(--paper-ink); padding-bottom:14px; margin-bottom:6px; }
  .p-band h2 { font-size:21px; font-weight:700; letter-spacing:-.01em; }
  .p-band .meta { margin-left:auto; text-align:right; font-family:var(--mono); font-size:9px; color:var(--paper-muted); line-height:1.7; }
  .p-athlete { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--paper-line); margin-bottom:14px; }
  .p-athlete .av { width:38px; height:38px; border-radius:10px; background:#1c2433; color:#fff; display:grid; place-items:center; font-weight:700; font-size:13px; }
  .p-athlete b { font-size:14px; } .p-athlete span { font-size:10.5px; color:var(--paper-muted); }
  .p-kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:0; border:1px solid var(--paper-line); border-radius:10px; overflow:hidden; margin-bottom:16px; }
  .p-kpi { padding:12px 14px; border-left:1px solid var(--paper-line); }
  .p-kpi:first-child { border-left:0; }
  .p-kpi .l { font-size:8.5px; text-transform:uppercase; letter-spacing:.12em; color:var(--paper-muted); font-weight:650; margin-bottom:5px; }
  .p-kpi .v { font-family:var(--mono); font-size:19px; font-weight:600; letter-spacing:-.02em; }
  .p-kpi .v small { font-size:10px; color:var(--paper-muted); font-weight:400; }
  .p-sec { display:flex; align-items:center; gap:10px; margin:18px 0 10px; }
  .p-sec b { font-size:12px; letter-spacing:.02em; }
  .p-sec .ln { flex:1; height:1px; background:var(--paper-line); }
  .p-sec .n { font-family:var(--mono); font-size:8.5px; color:var(--paper-muted); }
  .p-chart { border:1px solid var(--paper-line); border-radius:10px; padding:12px 14px 8px; margin-bottom:6px; }
  .p-chart .t { font-size:10px; font-weight:650; margin-bottom:6px; display:flex; gap:10px; }
  .p-chart .t small { color:var(--paper-muted); font-weight:400; }
  .p-chart svg { width:100%; display:block; }
  .p-note { font-size:9px; color:var(--paper-muted); margin-top:4px; line-height:1.5; }
  .p-metrics { width:100%; border-collapse:collapse; margin:8px 0 4px; }
  .p-metrics th { text-align:left; padding:5px 8px; font-size:8.5px; text-transform:uppercase; letter-spacing:.08em;
    color:var(--paper-muted); font-weight:650; border-bottom:1px solid var(--paper-line); }
  .p-metrics td { padding:5px 8px; font-size:11px; border-bottom:1px solid var(--paper-line); }
  .p-metrics td:nth-child(2) { font-family:var(--mono); }
  .p-concl { border-left:3px solid var(--paper-ink); padding:8px 0 8px 14px; margin:8px 0; }
  .p-concl.warm { border-left-color:var(--paper-warm); }
  .p-concl .txt { font-size:12px; line-height:1.75; }
  .p-concl .ref { font-family:var(--mono); font-size:8.5px; color:var(--paper-muted); margin-top:5px; }
  .p-concl .ref b { color:var(--pale-green-ink); font-weight:650; }
  .p-foot { display:flex; align-items:center; gap:12px; border-top:1px solid var(--paper-line); margin-top:22px; padding-top:12px;
    font-family:var(--mono); font-size:8.5px; color:var(--paper-muted); }
  @media print {
    body { padding: 12px 20px; }
    .p-chart, .p-concl { page-break-inside: avoid; }
    @page { margin: 1.5cm; }
  }
</style>
</head><body>
<div class="p-band"><h2>CMJ Force Report</h2><div class="meta">${dateStr}<br/>${esc(org)}</div></div>
<div class="p-athlete"><div class="av">${esc(initials)}</div><div><b>${esc(athleteName)}</b><br/><span>体重 ${meta.weight} kg · 采样 ${meta.frequency} Hz · 试次 ${jumps.length}</span></div></div>
<div class="p-kpis">${kpiCells}</div>
${jumpSections}
<div class="p-concl warm"><div class="txt">${esc(CMJ_CLASSIFICATION_DISCLAIMER)}</div><div class="ref">CMJ 分型 · 解读边界</div></div>
<div class="p-foot"><span>Generated ${gen} · Sports Science OS</span><span style="margin-left:auto">CMJ Force-Time Analysis</span></div>
</body></html>`;
    const w = window.open('', '_blank');
    if (!w) { alert('请允许弹出窗口以生成报告'); return; }
    w.document.write(html);
    w.document.close();
    w.focus();
    setTimeout(() => w.print(), 800);
  }

  // ── 7. COMPARISON TABLE ───────────────────────────────────────────────────
  // Two-stage display matching CMJSessionDetail's metric layout:
  //   Top   — selected trial detail, section-grouped (full readability)
  //   Bottom — compact multi-trial comparison, customizable metric set,
  //            with Best / Mean / CV% summary columns
  // The metric picker affects ONLY the bottom comparison; the top detail panel
  // always shows every metric that has a value for the selected trial.

  function ComparisonTable({ jumps, selectedMetricKeys, onToggleMetric, onResetMetrics, classifications, selectedIdx, onSelectJump, metricsView = 'table' }) {
    const [pickerOpen, setPickerOpen] = useState(false);

    const selectedJump = jumps[selectedIdx] || jumps[0];
    const selectedCl   = classifications?.[selectedIdx];
    const selectedMetrics = selectedJump?.metrics || {};

    // ── Build the SELECTED-TRIAL detail (top) ──────────────────────────────
    const detailDefs = ALL_SUMMARY_METRICS.filter(def => {
      const v = selectedMetrics[def.key];
      return v != null && isFinite(v);
    });
    const detailSections = [...new Set(detailDefs.map(d => d.section))];

    // ── Build the COMPARISON rows (bottom, customizable) ───────────────────
    const rows = selectedMetricKeys.map(key => {
      const def = ALL_SUMMARY_METRICS.find(m => m.key === key);
      if (!def) return null;
      const vals = jumps.map(j => {
        const v = j.metrics[key];
        return (v != null && isFinite(v)) ? v : null;
      });
      const valid = vals.filter(v => v != null);
      if (valid.length === 0) 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;
      const cv   = mean !== 0 ? sd / Math.abs(mean) * 100 : null;
      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); const d = s.includes('.') ? s.split('.')[1].length : 0; return Math.max(p, d);
      }, 0);
      const fmt = v => v != null ? v.toFixed(prec) : '—';
      return { ...def, vals, mean, cv, best, worst, fmt, prec };
    }).filter(Boolean);

    const sections = [...new Set(ALL_SUMMARY_METRICS.map(m => m.section))];

    // ── Compact display helpers ────────────────────────────────────────────
    const fmtCompact = v => {
      if (v == null || !isFinite(v)) return '—';
      const a = Math.abs(v);
      if (a >= 100) return v.toFixed(0);
      if (a >= 10)  return v.toFixed(1);
      return v.toFixed(2);
    };

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>

        {/* Header bar */}
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '10px 14px', borderBottom: '1px solid var(--border)',
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              指标详情
            </span>
            <span style={{ fontSize: 11, color: 'var(--text-2)' }}>
              Trial {selectedJump?.index}
              {selectedCl && (
                <span style={{ marginLeft: 6, color: selectedCl.isLF1 ? 'rgba(52,211,153,.9)' : 'rgba(251,191,36,.85)', fontWeight: 700 }}>
                  · {selectedCl.type}
                </span>
              )}
              {selectedCl?.isBimodal && <span style={{ marginLeft: 4, color: 'var(--muted)' }}>· bimodal</span>}
              {selectedCl && <span style={{ marginLeft: 4, color: 'var(--muted)' }}>· {selectedCl.isLF1 ? 'LF1' : 'LF2'}</span>}
            </span>
          </div>
          {jumps.length > 1 && (
            <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>

        {/* Metric picker (only affects the bottom comparison table) */}
        {pickerOpen && (
          <div style={{ margin: '10px 14px', padding: '12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
            {sections.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' }}>
                  {ALL_SUMMARY_METRICS.filter(m => m.section === sec).map(m => {
                    const checked = selectedMetricKeys.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={() => onToggleMetric(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>
            ))}
            <button onClick={() => onResetMetrics()} style={{
              marginTop: 4, fontSize: 10.5, color: 'var(--muted)', background: 'transparent',
              border: '1px solid var(--border)', borderRadius: 4, padding: '3px 10px', cursor: 'pointer',
            }}>恢复默认</button>
          </div>
        )}

        {/* ── PART A: Selected-trial detail, section-grouped ─────────────── */}
        {/* FL-T1: the table presentation form of the per-trial detail. Rendered */}
        {/* only when the caller's view toggle is 'table'; the 'cards' form (MCard */}
        {/* grids) lives in CMJPanel. PART B (cross-trial comparison) is unaffected. */}
        {metricsView === 'table' && (
        <div style={{ display: 'flex', flexDirection: 'column' }}>
          {detailSections.map(sec => (
            <div key={sec}>
              <div style={{
                padding: '6px 14px', fontSize: 9.5,
                color: SECTION_COLORS[sec] || 'var(--muted)', fontWeight: 700,
                textTransform: 'uppercase', letterSpacing: '.06em',
                background: 'var(--panel-hi)',
              }}>{sec}</div>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 0 }}>
                {detailDefs.filter(d => d.section === sec).map(d => (
                  <div key={d.key} style={{
                    padding: '6px 14px', borderBottom: '1px solid rgba(15,23,42,.05)',
                    display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8,
                  }}>
                    <span style={{ fontSize: 11, color: 'var(--text-2)' }}>{d.label}</span>
                    <span className="mono" style={{ fontSize: 12, color: 'var(--text)' }}>
                      {fmtCompact(selectedMetrics[d.key])}
                      {d.unit && <span style={{ color: 'var(--muted-2)', fontSize: 9.5, marginLeft: 2 }}>{d.unit}</span>}
                    </span>
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>
        )}

        {/* ── PART B: Compact multi-trial comparison (only when ≥2 trials) ── */}
        {jumps.length > 1 && rows.length > 0 && (() => {
          const colW = 78;
          const nameW = 150;
          return (
            <div style={{ borderTop: '1px solid var(--border)' }}>
              <div style={{
                padding: '8px 14px', fontSize: 10, color: 'var(--muted)',
                textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600,
                display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
              }}>
                <span>试次对比（{rows.length} 项指标）</span>
                <span style={{ fontWeight: 400, color: 'var(--muted-2)', fontSize: 10, textTransform: 'none', letterSpacing: 0 }}>
                  ▲ best · ▼ worst · CV%&gt;10 黄色高亮
                </span>
              </div>
              <div style={{ overflowX: 'auto' }}>
                <div style={{ minWidth: 'max-content' }}>
                  {/* Header row */}
                  <div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
                    <div style={{ width: nameW, minWidth: nameW, padding: '6px 14px', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
                      Metric
                    </div>
                    {jumps.map((j, i) => {
                      const cl = classifications?.[i];
                      const isActive = i === selectedIdx;
                      return (
                        <div key={j.index}
                          onClick={() => onSelectJump(i)}
                          style={{
                            width: colW, minWidth: colW, padding: '4px 8px', cursor: 'pointer',
                            fontSize: 10.5, color: isActive ? 'var(--accent-2)' : 'var(--muted)',
                            fontWeight: 600, textAlign: 'right',
                            display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 1,
                          }}>
                          <span>T{j.index}</span>
                          {cl && <span style={{ fontSize: 9, color: cl.isLF1 ? 'rgba(52,211,153,.7)' : 'rgba(251,191,36,.7)', fontWeight: 700 }}>{cl.type}</span>}
                        </div>
                      );
                    })}
                    <div style={{ width: colW, minWidth: colW, padding: '4px 8px', fontSize: 10.5, color: 'rgba(52,211,153,.8)', fontWeight: 600, textAlign: 'right' }}>Best</div>
                    <div style={{ width: colW, minWidth: colW, padding: '4px 8px', fontSize: 10.5, color: 'var(--muted)', fontWeight: 600, textAlign: 'right' }}>Mean</div>
                    <div style={{ width: colW, minWidth: colW, padding: '4px 14px 4px 8px', fontSize: 10.5, color: 'var(--muted)', fontWeight: 600, textAlign: 'right' }}>CV%</div>
                  </div>

                  {/* Metric rows */}
                  {rows.map((row, ri) => {
                    const secColor = SECTION_COLORS[row.section] || 'var(--muted)';
                    return (
                      <div key={row.key} style={{
                        display: 'flex', borderBottom: '1px solid rgba(15,23,42,.05)',
                        background: ri % 2 === 0 ? 'transparent' : 'rgba(15,23,42,.025)',
                      }}>
                        <div style={{ width: nameW, minWidth: nameW, padding: '5px 14px', display: 'flex', alignItems: 'center', gap: 5, whiteSpace: 'nowrap' }}>
                          <span style={{ fontSize: 9, color: secColor, fontWeight: 700, 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)' }}>{row.unit}</span>}
                        </div>
                        {jumps.map((j, i) => {
                          const val = row.vals[i];
                          const isBest  = row.best  != null && val != null && val === row.best;
                          const isWorst = row.worst != null && val != null && val === row.worst && val !== row.best;
                          const isActive = i === selectedIdx;
                          const marker = isBest ? '▲' : isWorst ? '▼' : '';
                          return (
                            <div key={j.index}
                              onClick={() => onSelectJump(i)}
                              style={{
                                width: colW, minWidth: colW, padding: '5px 8px', textAlign: 'right', cursor: 'pointer',
                                fontFamily: 'var(--font-mono)', fontSize: 11.5,
                                color: isBest ? 'rgba(52,211,153,.95)' :
                                       isWorst ? 'rgba(248,113,113,.75)' :
                                       isActive ? 'var(--text)' : 'var(--text-2)',
                                fontWeight: isBest ? 700 : isActive ? 600 : 400,
                              }}>
                              {marker && <span style={{ fontSize: 8, marginRight: 2, opacity: .8 }}>{marker}</span>}
                              {val != null ? row.fmt(val) : '—'}
                            </div>
                          );
                        })}
                        <div style={{ width: colW, minWidth: colW, padding: '5px 8px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 11.5, color: row.best != null ? 'rgba(52,211,153,.85)' : 'var(--muted-2)', fontWeight: 600 }}>
                          {row.best != null ? row.fmt(row.best) : '—'}
                        </div>
                        <div style={{ width: colW, minWidth: colW, padding: '5px 8px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--muted)' }}>
                          {row.fmt(row.mean)}
                        </div>
                        <div style={{
                          width: colW, minWidth: colW, padding: '5px 14px 5px 8px',
                          textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: 11.5,
                          color: row.cv != null && row.cv > 10 ? 'rgba(251,191,36,.85)' : 'rgba(15,23,42,.4)',
                        }}>
                          {row.cv != null ? row.cv.toFixed(1) + '%' : '—'}
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          );
        })()}
      </div>
    );
  }

  // ── 8. MAIN PANEL ─────────────────────────────────────────────────────────

  function CMJPanel({ athletes = [], defaultAthleteId = null, cmjStore = {}, onSaveSession = null,
                      result: resultProp = undefined, onResultChange = null, onExportReport = null,
                      queuedFile = null, onQueuedFileProcessed = null,
                      reanalyzeFile = null, onReanalyzeFileProcessed = null }) {
    // Support controlled mode: if both resultProp and onResultChange are supplied,
    // the caller owns the result state (persists across navigation).
    const controlled = resultProp !== undefined && onResultChange !== null;
    const [localResult, setLocalResult] = useState(null);
    const result    = controlled ? resultProp    : localResult;
    const setResult = controlled ? onResultChange : setLocalResult;

    const [error,           setError]           = useState(null);
    const [loading,         setLoading]         = useState(false);
    const [dragOver,        setDragOver]        = useState(false);
    const [selectedIdx,     setSelectedIdx]     = useState(0);
    const [pendingResult,   setPendingResult]   = useState(null);
    const [manualStartSec,  setManualStartSec]  = useState('');
    const [manualEndSec,    setManualEndSec]    = useState('');
    const [rangeDragStart,  setRangeDragStart]  = useState(null);
    const [overlays,        setOverlays]        = useState({ vel: true, disp: false, acc: false, power: false });
    const [normalizeX,      setNormalizeX]      = useState(false);
    const [showLoops,       setShowLoops]       = useState(false);
    const [compareSelected, setCompareSelected] = useState(new Set());
    // Owen 2014 / Hawkin Dynamics: onset 30 ms earlier than 5SD crossing.
    // VALD ForceDecks does NOT apply this shift. Default: ON (academic standard).
    const [useOnsetBackshift, setUseOnsetBackshift] = useState(() => {
      try { const v = localStorage.getItem('cmj-onset-backshift'); return v == null ? true : v === 'true'; }
      catch { return true; }
    });
    const fileRef = useRef(null);
    const processingGenerationRef = useRef(0);
    const processingAthleteRef = useRef(defaultAthleteId);

    // ── Save-to-athlete state ────────────────────────────────────────────────
    const [saveAthleteId, setSaveAthleteId] = useState(defaultAthleteId || (athletes[0]?.id ?? null));
    const [saveStatus,    setSaveStatus]    = useState(null); // null | 'saved' | 'error'
    const [saving,        setSaving]        = useState(false); // in-flight save saga (double-click guard)
    const [representativeIdx, setRepresentativeIdx] = useState(0);
    const [repUserChanged, setRepUserChanged] = useState(false);
    const [saveDate, setSaveDate] = useState('');
    const [saveSourceCsv, setSaveSourceCsv] = useState(() => {
      try { return localStorage.getItem('force-save-source-csv') === 'true'; } catch { return false; }
    });
    // Keep saveAthleteId in sync when the caller updates defaultAthleteId (e.g. navigating from a different athlete page)
    useEffect(() => { if (defaultAthleteId != null) setSaveAthleteId(defaultAthleteId); }, [defaultAthleteId]);
    useEffect(() => {
      if (processingAthleteRef.current != null && processingAthleteRef.current !== defaultAthleteId) {
        processingGenerationRef.current += 1;
        setPendingResult(null);
        setResult(null);
      }
      processingAthleteRef.current = defaultAthleteId;
    }, [defaultAthleteId, setResult]);
    useEffect(() => () => { processingGenerationRef.current += 1; }, []);
    // Reset representative choice and sync date when a new file is loaded
    useEffect(() => {
      setRepresentativeIdx(0); setRepUserChanged(false); setSaveStatus(null);
      setSaveDate(result?.meta?.date?.slice(0, 10) || new Date().toISOString().slice(0, 10));
    }, [result]);

    const runCMJDetection = useCallback((base, options = {}) => {
      const { meta, time, left, right } = base;
      const total = base.total || left.map((l, i) => l + right[i]);
      const durationS = time.length > 1 ? time[time.length - 1] - time[0] : 0;
      const isShort = options.isShortRecording ?? (durationS > 0 && (
        durationS < 5.0 ||
        (Number.isFinite(options.rangeStartSec) && Number.isFinite(options.rangeEndSec) && (options.rangeEndSec - options.rangeStartSec) < 5.0)
      ));
      const sampling = window.ForceCoreSampling?.samplingProfile(time, meta.frequency) || null;
      const { jumps } = detectAllJumps(time, total, left, right, meta.weight, isShort, useOnsetBackshift, options);
      const classifications = jumps.map(j => classifyCMJJump(total, j, meta.frequency));
      return { ...base, total, jumps, classifications, sampling };
    }, [useOnsetBackshift]);

    const acceptPendingResult = useCallback(() => {
      if (!pendingResult) return;
      setSelectedIdx(0);
      setCompareSelected(new Set(pendingResult.jumps.map((_, i) => i)));
      setResult(pendingResult);
      setPendingResult(null);
      setManualStartSec('');
      setManualEndSec('');
      setError(null);
    }, [pendingResult, setResult]);

    const applyManualRange = useCallback((mode = 'append') => {
      if (!pendingResult) return;
      const start = parseFloat(manualStartSec);
      const end = parseFloat(manualEndSec);
      if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
        setError('请输入有效的手动截取范围，例如 34 到 38 秒。');
        return;
      }
      try {
        const next = runCMJDetection(pendingResult, { detectionMode: 'manual_range', rangeStartSec: start, rangeEndSec: end });
        const current = pendingResult.jumps || [];
        const insertAt = mode === 'replace_current'
          ? Math.min(Math.max(0, selectedIdx), Math.max(0, current.length - 1))
          : current.length;
        const prior = mode === 'replace_all'
          ? []
          : mode === 'replace_current'
            ? current.filter((_, i) => i !== insertAt)
            : current;
        const adjusted = next.jumps.map((j, i) => ({ ...j, index: insertAt + i + 1 }));
        const merged = mode === 'replace_current'
          ? [...prior.slice(0, insertAt), ...adjusted, ...prior.slice(insertAt)]
          : [...prior, ...adjusted];
        const jumps = merged.map((j, i) => ({ ...j, index: i + 1 }));
        const classifications = jumps.map(j => classifyCMJJump(pendingResult.total, j, pendingResult.meta.frequency));
        setPendingResult({ ...pendingResult, jumps, classifications });
        setSelectedIdx(mode === 'replace_all' ? 0 : Math.min(insertAt, Math.max(0, jumps.length - 1)));
        setCompareSelected(new Set(jumps.map((_, i) => i)));
        setError(null);
      } catch (err) {
        setError(err.message);
      }
    }, [pendingResult, manualStartSec, manualEndSec, runCMJDetection, selectedIdx]);

    const deleteSelectedPendingJump = useCallback(() => {
      if (!pendingResult?.jumps?.length) return;
      const jumps = pendingResult.jumps
        .filter((_, i) => i !== selectedIdx)
        .map((j, i) => ({ ...j, index: i + 1 }));
      const classifications = jumps.map(j => classifyCMJJump(pendingResult.total, j, pendingResult.meta.frequency));
      setPendingResult({ ...pendingResult, jumps, classifications });
      setSelectedIdx(Math.min(selectedIdx, Math.max(0, jumps.length - 1)));
      setCompareSelected(new Set(jumps.map((_, i) => i)));
      setError(null);
    }, [pendingResult, selectedIdx]);

    const resetPendingAuto = useCallback(() => {
      if (!pendingResult?.autoJumps) return;
      const jumps = pendingResult.autoJumps.map((j, i) => ({ ...j, index: i + 1 }));
      const classifications = jumps.map(j => classifyCMJJump(pendingResult.total, j, pendingResult.meta.frequency));
      setPendingResult({ ...pendingResult, jumps, classifications });
      setError(null);
    }, [pendingResult]);

    // FORCE-TRACE M2b (audit-fix): build the full-window trace for EVERY accepted valid trial
    // (result.jumps), not just the representative — so a later representative switch shows that
    // trial's real F-t, never a re-derived compact curve. CMJ-only, raw-axis-only. Outcome:
    //   'none'    → ineligible (MARS summary / external-precomputed / no raw axis) — no trace.
    //   'failed'  → eligible but a build threw — session records traceRef.status='missing' + reason.
    //   'ok'      → records[] for all valid trials (single saveMany transaction).
    const buildAllTraceRecords = (session) => {
      const ar = session.algorithmRef;
      const eligible = !!window.CMJTraceBuilder && result && Array.isArray(result.time) && result.time.length
        && ar && ar.testType === 'cmj' && ar.sampling
        && (ar.sampling.status === 'valid' || ar.sampling.status === 'limited' || ar.sampling.status === 'non-uniform');
      const jumps = (result && result.jumps) || [];
      const fileName = session.fileName || (result && result.meta && result.meta._fileName) || '';
      const trialIndexOf = (i) => (session.trials[i] && session.trials[i].index) ?? (jumps[i] && jumps[i].index) ?? (i + 1);
      const expectedTrialIndices = jumps.map((_, i) => trialIndexOf(i));
      const representativeTrialIndex = session.representative?.index ?? expectedTrialIndices[0] ?? 0;
      if (!eligible || !jumps.length || !fileName) return { records: [], outcome: 'none', representativeTrialIndex, expectedTrialIndices: [] };
      const records = [];
      const generatedAt = new Date().toISOString();
      for (let i = 0; i < jumps.length; i++) {
        const jump = jumps[i];
        const trialIndex = trialIndexOf(i);
        if (!jump || !Array.isArray(jump.vel) || !jump.vel.length || !Array.isArray(jump.disp) || !jump.phases)
          return { records: [], outcome: 'failed', reason: 'trial ' + i + ' lacks raw kinematics', representativeTrialIndex, expectedTrialIndices, failedTrialIndex: trialIndex };
        try {
          records.push(window.CMJTraceBuilder.buildCmjTrace({
            time: result.time, left: result.left, right: result.right, total: result.total,
            jump: { phases: jump.phases, vel: jump.vel, disp: jump.disp, quietRef: jump.quietRef },
            trialIndex, sessionId: session.id, athleteId: saveAthleteId,
            algorithmRef: ar, generatedAt, sourceFileName: fileName,
            detectionMode: jump.detection?.mode || 'auto',
          }));
        } catch (e) {
          console.warn('CMJ trace build failed for trial', trialIndex, e && e.message);
          return { records: [], outcome: 'failed', reason: (e && e.message) || 'build error', representativeTrialIndex, expectedTrialIndices, failedTrialIndex: trialIndex };
        }
      }
      return { records, outcome: 'ok', representativeTrialIndex, expectedTrialIndices };
    };

    const handleSave = async () => {
      if (!result || !saveAthleteId || !onSaveSession || saving) return;   // double-click guard
      setSaving(true);
      try {
        const session = buildCMJSession(result, result.meta._fileName || '', representativeIdx, useOnsetBackshift, repUserChanged, saveDate);
        const built = buildAllTraceRecords(session);
        const report = await onSaveSession(saveAthleteId, session, built, saveSourceCsv ? result.meta._sourceFile : null);
        // Only a CONFIRMED persist is a success — a saga that could not persist the session
        // (quota / write failure) must not read as saved (GPT F1).
        setSaveStatus(report && report.persisted ? 'saved' : 'error');
      } catch { setSaveStatus('error'); }
      finally { setSaving(false); }
    };

    const [selectedMetricKeys, setSelectedMetricKeys] = useState(() => {
      try {
        const s = localStorage.getItem('cmj-summary-metrics');
        return s ? JSON.parse(s) : DEFAULT_METRIC_KEYS;
      } catch { return DEFAULT_METRIC_KEYS; }
    });

    const toggleMetric = key => {
      setSelectedMetricKeys(prev => {
        const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key];
        try { localStorage.setItem('cmj-summary-metrics', JSON.stringify(next)); } catch {}
        return next;
      });
    };

    const resetMetrics = () => {
      setSelectedMetricKeys(DEFAULT_METRIC_KEYS);
      try { localStorage.setItem('cmj-summary-metrics', JSON.stringify(DEFAULT_METRIC_KEYS)); } catch {}
    };

    // FL-T1: single-session metric presentation form — 'cards' (MCard grids)
    // ⇄ 'table' (compact section-grouped rows). Pure presentation container
    // toggle; persisted via window.VizPanelPrefsRepo, 'cmj-metrics-view' domain
    // ({ view }). Falls back to the default 'cards' view if the repo isn't ready.
    const metricsViewRepo = React.useMemo(() => window.VizPanelPrefsRepo || null, []);
    const [metricsView, setMetricsView] = useState(() => {
      if (metricsViewRepo) return metricsViewRepo.load('cmj-metrics-view').view;
      return 'cards';
    });
    const updateMetricsView = view => {
      setMetricsView(view);
      if (metricsViewRepo) metricsViewRepo.save('cmj-metrics-view', { view });
    };

    const toggleOverlay = key => setOverlays(o => ({ ...o, [key]: !o[key] }));
    const toggleCompare = idx => setCompareSelected(s => {
      const ns = new Set(s);
      ns.has(idx) ? ns.delete(idx) : ns.add(idx);
      return ns;
    });

    const process = useCallback(async (file, intake = null) => {
      if (!file) return;
      const generation = ++processingGenerationRef.current;
      // Don't clear result yet — only replace on success so old analysis is preserved on failure
      setLoading(true); setError(null); setSelectedIdx(0); setRepresentativeIdx(0); setSaveStatus(null);
      try {
        const parsed = await parseVALDFile(file);
        const { meta, time, left, right, _precomputedJumps } = parsed;
        meta._fileName = file.name;
        meta._sourceFile = file;
        if (intake) meta._intake = intake;

        let jumps, total, detected;
        if (meta.isMARSSummary && _precomputedJumps) {
          // P0: MARS Summary file — jumps already contain pre-computed metrics, skip detection
          jumps = _precomputedJumps;
          total = [];
        } else {
          total = left.map((l, i) => l + right[i]);
          detected = runCMJDetection({ meta, time, total, left, right }, { detectionMode: 'auto' });
          jumps = detected.jumps;
        }

        const classifications = detected?.classifications || jumps.map(j => classifyCMJJump(total, j, meta.frequency));
        const sampling = detected?.sampling || window.ForceCoreSampling?.samplingProfile(time, meta.frequency) || null;
        const nextResult = { meta, time, total, left, right, jumps, classifications, sampling };
        if (generation !== processingGenerationRef.current) return { ok: false, error: 'target_changed' };
        if (meta.isMARSSummary) {
          setCompareSelected(new Set(jumps.map((_, i) => i)));
          setResult(nextResult);
          setPendingResult(null);
        } else {
          setCompareSelected(new Set(jumps.map((_, i) => i)));
          setPendingResult({ ...nextResult, autoJumps: jumps });
          setResult(null);
        }
        return { ok: true, reviewRequired: !meta.isMARSSummary };
      } catch (err) {
        if (generation === processingGenerationRef.current) setError(err.message);
        return { ok: false, error: err.message || '文件解析失败。' };
      } finally {
        if (generation === processingGenerationRef.current) setLoading(false);
        if (fileRef.current) fileRef.current.value = '';
      }
    }, [runCMJDetection, setResult]);

    const processedQueueItemRef = useRef(null);
    useEffect(() => {
      if (!queuedFile || queuedFile.type !== 'cmj' || processedQueueItemRef.current === queuedFile.itemId) return;
      processedQueueItemRef.current = queuedFile.itemId;
      const intake = { version: 1, source: 'test_day_batch', originalFileName: queuedFile.file.name };
      process(queuedFile.file, intake).then(outcome => onQueuedFileProcessed?.(queuedFile.itemId, outcome));
    }, [queuedFile, onQueuedFileProcessed, process]);
    const processedReanalysisRef = useRef(null);
    useEffect(() => {
      if (!reanalyzeFile || reanalyzeFile.type !== 'cmj' || processedReanalysisRef.current === reanalyzeFile.sessionId) return;
      processedReanalysisRef.current = reanalyzeFile.sessionId;
      const intake = { version: 1, source: 'saved_source_reanalysis', originalSessionId: reanalyzeFile.sessionId, originalFileName: reanalyzeFile.file.name };
      process(reanalyzeFile.file, intake).then(outcome => onReanalyzeFileProcessed?.(reanalyzeFile.sessionId, outcome));
    }, [reanalyzeFile, onReanalyzeFileProcessed, process]);

    // When user flips the onset-backshift toggle, re-run detection on the same raw data.
    // No re-parsing needed — just re-detect from the cached time/left/right arrays.
    const toggleOnsetBackshift = useCallback(() => {
      setUseOnsetBackshift(prev => {
        const next = !prev;
        try { localStorage.setItem('cmj-onset-backshift', String(next)); } catch {}
        const rerun = (source) => {
          const hasManualTrials = (source.jumps || []).some(j => j.detection?.mode === 'manual_range');
          if (hasManualTrials) {
            setError('手动截取结果已存在。若需改变 onset 回推设置，请重新上传或恢复自动截取后再切换。');
            return source;
          }
          const durationS = source.time[source.time.length - 1] - source.time[0];
          const isShort = durationS > 0 && durationS < 5.0;
          const { jumps } = detectAllJumps(
            source.time, source.total, source.left, source.right,
            source.meta.weight, isShort, next, { detectionMode: 'auto' }
          );
          const classifications = jumps.map(j => classifyCMJJump(source.total, j, source.meta.frequency));
          return { ...source, jumps, classifications, autoJumps: jumps };
        };
        if (pendingResult && !pendingResult.meta.isMARSSummary && pendingResult.time?.length > 1) {
          try {
            setPendingResult(rerun(pendingResult));
          } catch (err) { setError(err.message); }
        } else if (result && !result.meta.isMARSSummary && result.time?.length > 1) {
          try {
            const updated = rerun(result);
            setResult({ ...updated, autoJumps: undefined });
          } catch (err) { setError(err.message); }
        }
        return next;
      });
    }, [pendingResult, result, setResult]);

    const handleChange = useCallback(e => process(e.target.files?.[0]), [process]);
    const handleDrop   = useCallback(e => { e.preventDefault(); process(e.dataTransfer.files?.[0]); }, [process]);

    // FL-2: section subtitle (2nd arg) is explanatory/reference text (phase window,
    // ICC/threshold notes) — hover-only via MetricInfo, matching MCard's formula/cite
    // affordance. The section title itself stays always-visible (primary anchor).
    const section = (title, sub) => (
      <div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
        <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em' }}>{title}</div>
        {sub && <MetricInfo formula={sub} />}
      </div>
    );

    const toggleBtn = (label, active, onClick, color, reactKey) => (
      <button key={reactKey || label} onClick={onClick} style={{
        display: 'inline-flex', alignItems: 'center', gap: 6,
        padding: '4px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
        background: active ? 'rgba(15,23,42,.07)' : 'transparent',
        border: `1px solid ${active ? (color || 'var(--accent)') : 'var(--border)'}`,
        color: active ? (color || 'var(--accent)') : 'var(--muted)',
        fontFamily: 'var(--font-sans)',
      }}>
        <span style={{ width: 8, height: 8, borderRadius: '50%', background: active ? (color || 'var(--accent)') : 'var(--muted-2)', flexShrink: 0 }} />
        {label}
      </button>
    );

    const renderDetectionReview = () => {
      if (!pendingResult) return null;
      const { meta, time, total, jumps } = pendingResult;
      const duration = time.length ? time[time.length - 1] - time[0] : 0;
      const selectedJump = jumps[Math.min(selectedIdx, jumps.length - 1)] || jumps[0];
      const bw = meta.weight * G;
      const W = 920, H = 230, ML = 42, MR = 14, MT = 14, MB = 28;
      const PW = W - ML - MR, PH = H - MT - MB;
      const step = Math.max(1, Math.ceil(total.length / 1200));
      const pts = [];
      for (let i = 0; i < total.length; i += step) pts.push({ i, t: time[i], f: total[i] });
      if (total.length) {
        const i = total.length - 1;
        pts.push({ i, t: time[i], f: total[i] });
      }
      let fMin = Math.min(...pts.map(p => p.f), bw * 0.8);
      let fMax = Math.max(...pts.map(p => p.f), bw * 1.6);
      const pad = Math.max(20, (fMax - fMin) * 0.08);
      fMin -= pad; fMax += pad;
      const x = idx => ML + ((time[idx] - time[0]) / (duration || 1)) * PW;
      const y = f => MT + (1 - (f - fMin) / ((fMax - fMin) || 1)) * PH;
      const lineD = pts.map((p, i) => `${i ? 'L' : 'M'}${x(p.i).toFixed(1)},${y(p.f).toFixed(1)}`).join(' ');
      const marker = (idx, color, label, dash = false) => idx == null ? null : (
        <g key={label + idx}>
          <line x1={x(idx)} y1={MT} x2={x(idx)} y2={MT + PH} stroke={color} strokeWidth="1.2" strokeDasharray={dash ? '4 4' : 'none'} />
          <text x={x(idx) + 3} y={MT + 10} fontSize="9" fill={color}>{label}</text>
        </g>
      );
      const manualStart = parseFloat(manualStartSec);
      const manualEnd = parseFloat(manualEndSec);
      const hasManualRange = Number.isFinite(manualStart) && Number.isFinite(manualEnd) && manualEnd > manualStart;
      const clampSec = sec => Math.max(time[0] || 0, Math.min(time[time.length - 1] || 0, sec));
      const secFromEvent = e => {
        const svg = e.currentTarget;
        const rect = svg.getBoundingClientRect();
        const px = (e.clientX - rect.left) / rect.width * W;
        const ratio = Math.max(0, Math.min(1, (px - ML) / PW));
        return +(time[0] + ratio * duration).toFixed(3);
      };
      const setManualRange = (a, b) => {
        let lo = clampSec(Math.min(a, b));
        let hi = clampSec(Math.max(a, b));
        if (hi - lo < 0.05) {
          lo = clampSec(lo - 0.25);
          hi = clampSec(hi + 0.25);
        }
        setManualStartSec(lo.toFixed(3));
        setManualEndSec(hi.toFixed(3));
      };
      const xr = sec => ML + ((sec - time[0]) / (duration || 1)) * PW;
      const hasManualTrials = jumps.some(j => j.detection?.mode === 'manual_range');

      return (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{
            background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 8,
            padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10,
          }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
              <div>
                <div style={{ fontSize: 13, fontWeight: 650, color: 'var(--text)' }}>截取预览</div>
                <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
                  自动识别到 {jumps.length} 个 trial。可在曲线上横向拖拽选择范围，或用秒数微调后重新截取。
                </div>
              </div>
              <button onClick={acceptPendingResult} disabled={!jumps.length} style={{
                fontSize: 11, padding: '6px 14px', borderRadius: 6,
                cursor: jumps.length ? 'pointer' : 'not-allowed',
                background: jumps.length ? 'var(--accent)' : 'var(--panel-2)',
                border: '1px solid ' + (jumps.length ? 'var(--accent)' : 'var(--border)'),
                color: jumps.length ? '#fff' : 'var(--muted)', fontFamily: 'var(--font-sans)',
              }}>使用当前截取并分析</button>
            </div>

            <svg
              viewBox={`0 0 ${W} ${H}`}
              onPointerDown={e => {
                e.preventDefault();
                const sec = secFromEvent(e);
                setRangeDragStart(sec);
                setManualRange(sec, sec);
                e.currentTarget.setPointerCapture?.(e.pointerId);
              }}
              onPointerMove={e => {
                if (rangeDragStart == null) return;
                e.preventDefault();
                setManualRange(rangeDragStart, secFromEvent(e));
              }}
              onPointerUp={e => {
                if (rangeDragStart == null) return;
                e.preventDefault();
                setManualRange(rangeDragStart, secFromEvent(e));
                setRangeDragStart(null);
                e.currentTarget.releasePointerCapture?.(e.pointerId);
              }}
              onPointerCancel={() => setRangeDragStart(null)}
              style={{ width: '100%', height: 240, background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'crosshair', touchAction: 'none', userSelect: 'none' }}>
              <line x1={ML} y1={y(bw)} x2={ML + PW} y2={y(bw)} stroke="rgba(15,23,42,.24)" strokeDasharray="4 4" />
              <text x={ML + 4} y={y(bw) - 5} fontSize="9" fill="rgba(15,23,42,.45)">BW</text>
              {hasManualRange && (
                <rect x={xr(manualStart)} y={MT} width={Math.max(0, xr(manualEnd) - xr(manualStart))} height={PH} fill="rgba(59,130,246,.10)" stroke="rgba(59,130,246,.35)" />
              )}
              {jumps.map((j, idx) => {
                const p = j.phases || {};
                const a = p.onset ?? p.takeoff;
                const b = p.landing ?? p.takeoff;
                const active = idx === selectedIdx;
                return (
                  <g key={idx}>
                    <rect x={x(a)} y={MT} width={Math.max(1, x(b) - x(a))} height={PH}
                      fill={active ? 'rgba(52,211,153,.14)' : 'rgba(15,23,42,.045)'}
                      stroke={active ? 'rgba(52,211,153,.55)' : 'rgba(15,23,42,.12)'} />
                    <text x={x(p.takeoff) + 4} y={MT + PH - 8} fontSize="10" fill={active ? 'rgba(5,150,105,.95)' : 'rgba(15,23,42,.45)'}>T{idx + 1}</text>
                  </g>
                );
              })}
              <path d={lineD} fill="none" stroke="rgba(15,23,42,.74)" strokeWidth="1.1" />
              {selectedJump && marker(selectedJump.phases?.onset, 'rgba(59,130,246,.9)', 'onset', true)}
              {selectedJump && marker(selectedJump.phases?.takeoff, 'rgba(16,185,129,.95)', 'TO')}
              {selectedJump && marker(selectedJump.phases?.landing, 'rgba(168,85,247,.9)', 'LD')}
              <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.25)" />
              {[0, .25, .5, .75, 1].map(r => {
                const tx = ML + r * PW;
                const tv = time[0] + r * duration;
                return <text key={r} x={tx} y={H - 8} textAnchor="middle" fontSize="10" fill="var(--muted)">{tv.toFixed(1)}s</text>;
              })}
            </svg>

            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
              {jumps.map((j, idx) => {
                const active = idx === selectedIdx;
                const flags = j.detection?.qualityFlags || [];
                return (
                  <button key={idx} onClick={() => setSelectedIdx(idx)} style={{
                    display: 'inline-flex', alignItems: 'center', gap: 7,
                    padding: '5px 9px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                    background: active ? 'var(--accent-soft)' : 'var(--panel-2)',
                    border: `1px solid ${active ? 'rgba(59,130,246,.4)' : 'var(--border)'}`,
                    color: active ? 'var(--accent-2)' : 'var(--text-2)',
                    fontFamily: 'var(--font-sans)',
                  }}>
                    <span style={{ fontWeight: 700 }}>T{idx + 1}</span>
                    <span className="mono">{time[j.phases.takeoff].toFixed(3)}s</span>
                    <span>{j.metrics.jumpHeight}cm</span>
                    {j.detection?.mode === 'manual_range' && <span style={{ color: 'rgba(59,130,246,.9)' }}>手动</span>}
                    {flags.length > 0 && <span title={flags.join(', ')} style={{ color: '#d97706' }}>需复核</span>}
                  </button>
                );
              })}
            </div>

            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', paddingTop: 2 }}>
              <span style={{ fontSize: 11, color: 'var(--muted)' }}>手动范围</span>
              <input type="number" step="0.001" value={manualStartSec} onChange={e => setManualStartSec(e.target.value)}
                placeholder="开始秒" style={{ width: 90, fontSize: 11, background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 5, color: 'var(--text)', padding: '5px 7px' }} />
              <span style={{ fontSize: 11, color: 'var(--muted)' }}>to</span>
              <input type="number" step="0.001" value={manualEndSec} onChange={e => setManualEndSec(e.target.value)}
                placeholder="结束秒" style={{ width: 90, fontSize: 11, background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 5, color: 'var(--text)', padding: '5px 7px' }} />
              <button onClick={() => applyManualRange('append')} style={{
                fontSize: 11, padding: '5px 11px', borderRadius: 6, cursor: 'pointer',
                background: 'transparent', border: '1px solid var(--accent)', color: 'var(--accent)', fontFamily: 'var(--font-sans)',
              }}>添加跳次</button>
              <button onClick={() => applyManualRange('replace_current')} disabled={!jumps.length} style={{
                fontSize: 11, padding: '5px 11px', borderRadius: 6, cursor: jumps.length ? 'pointer' : 'not-allowed',
                background: 'transparent', border: '1px solid rgba(245,158,11,.55)', color: jumps.length ? '#d97706' : 'var(--muted)', fontFamily: 'var(--font-sans)',
              }}>替换当前跳次</button>
              <button onClick={deleteSelectedPendingJump} disabled={!jumps.length} style={{
                fontSize: 11, padding: '5px 11px', borderRadius: 6, cursor: jumps.length ? 'pointer' : 'not-allowed',
                background: 'transparent', border: '1px solid rgba(239,68,68,.45)', color: jumps.length ? '#dc2626' : 'var(--muted)', fontFamily: 'var(--font-sans)',
              }}>删除当前跳次</button>
              <button onClick={() => applyManualRange('replace_all')} style={{
                fontSize: 11, padding: '5px 11px', borderRadius: 6, cursor: 'pointer',
                background: 'transparent', border: '1px solid var(--border)', color: 'var(--muted)', fontFamily: 'var(--font-sans)',
              }}>替换为手动列表</button>
              {hasManualTrials && (
                <button onClick={resetPendingAuto} style={{
                  fontSize: 11, padding: '5px 11px', borderRadius: 6, cursor: 'pointer',
                  background: 'transparent', border: '1px solid var(--border)', color: 'var(--muted)', fontFamily: 'var(--font-sans)',
                }}>恢复自动截取</button>
              )}
              <span style={{ fontSize: 10.5, color: 'var(--muted)' }}>
                先选中错误 trial，再拖正确范围并替换；漏识别则添加跳次。
              </span>
            </div>
          </div>
        </div>
      );
    };

    return (
      <div style={{ flex: 1, minWidth: 0, padding: '20px 24px 48px', display: 'flex', flexDirection: 'column', gap: 14, overflowY: 'auto' }}>

        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 15, fontWeight: 600 }}>CMJ Force-Time Analysis</span>
          <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',
          }}>双力板 · 双侧分析</span>
          <span style={{ marginLeft: 'auto' }} />
          <label data-force-source-save-option style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--text-2)', cursor: 'pointer' }}
            title="启用后，后续保存的 CMJ 会话会同时在本机保存原始 CSV，以便重新分析">
            <input type="checkbox" checked={saveSourceCsv} onChange={event => {
              const next = event.target.checked;
              setSaveSourceCsv(next);
              try { localStorage.setItem('force-save-source-csv', String(next)); } catch {}
            }}/>
            保存后续原始 CSV
          </label>
          {onExportReport && saveAthleteId && ((cmjStore[saveAthleteId] || []).length > 0) && (
            <button onClick={() => onExportReport(saveAthleteId)}
              style={{
                fontSize: 11, padding: '4px 12px', borderRadius: 7, cursor: 'pointer',
                background: 'var(--ink)', color: '#fff', border: '1px solid var(--ink)',
                fontFamily: 'var(--font-sans)', fontWeight: 500,
                display: 'inline-flex', alignItems: 'center', gap: 6,
              }}>
              <span style={{ fontFamily: 'var(--font-mono)', opacity: .85 }}>⎙</span> 导出报告
            </button>
          )}
          {(result || pendingResult) && (
            <>
              {result && (
                <button onClick={() => generateReport(result.meta, result.jumps, result.classifications, result.time, result.total)}
                  style={{ fontSize: 11, color: 'var(--accent)', background: 'none', border: '1px solid var(--border)', borderRadius: 6, padding: '3px 10px', cursor: 'pointer' }}>
                  打印报告
                </button>
              )}
              <button onClick={() => { setResult(null); setPendingResult(null); setError(null); setSelectedIdx(0); setSaveStatus(null); }}
                style={{ marginLeft: 4, fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer' }}>
                Load another file
              </button>
            </>
          )}
        </div>

        {/* P5-1: 公共上传区壳 */}
        <ForceTestUploadZone
          fileRef={fileRef} hasResult={!!result || !!pendingResult || loading} dragOver={dragOver}
          onDrop={handleDrop} setDragOver={setDragOver} onFileChange={handleChange}
          onReupload={() => fileRef.current?.click()}
          hint="VALD ForceDecks · General/CM 格式自动识别"
          accept=".xlsx,.xls,.csv,.txt,.tsv"
        />

        {/* Loading / Error */}
        <ForceTestLoadingError loading={loading} error={error} onRetry={() => { setError(null); fileRef.current?.click(); }} />

        {(pendingResult || result)?.sampling?.status === 'non-uniform' && (
          <div data-force-sampling-warning="non-uniform" style={{
            background: 'rgba(245,158,11,.08)', border: '1px solid rgba(245,158,11,.32)',
            borderRadius: 8, padding: '9px 12px', fontSize: 11, lineHeight: 1.55, color: '#9a6700',
          }}>
            <strong>⚠ 采样时间轴存在间隔或抖动。</strong>
            {' '}系统仍按中位采样间隔（{(pendingResult || result).sampling.medianDtMs} ms）完成分析；
            指标仅供参考，并且不会进入跨会话趋势或比较。
          </div>
        )}

        {!loading && pendingResult && renderDetectionReview()}

        {result && (() => {
          const { meta, time, total, left, right, jumps, classifications } = result;
          const jump = jumps[selectedIdx] || jumps[0];
          const { phases, metrics: m, vel, disp, quietRef } = jump;
          const classResult = classifications ? (classifications[selectedIdx] || classifications[0]) : null;
          const velAt  = i => { const k = i - quietRef; return (k >= 0 && k < vel.length)  ? vel[k]  : 0; };
          const dispAt = i => { const k = i - quietRef; return (k >= 0 && k < disp.length) ? disp[k] : 0; };

          const dateStr = meta.date
            ? (() => { try { return new Date(meta.date).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); } catch { return meta.date; } })()
            : '';

          return (
            <>
              {/* File metadata */}
              <div style={{ display: 'flex', gap: 16, fontSize: 11, color: 'var(--muted)', flexWrap: 'wrap', alignItems: 'center' }}>
                {dateStr && <span>{dateStr}</span>}
                <span>
                  体重 <span className="mono" style={{ color: 'var(--text-2)' }}>{meta.weight} kg</span>
                  {meta.weightEstimated && (
                    <span title="文件中无体重记录，已从静止站立阶段（最低SD窗口）自动估算" style={{
                      marginLeft: 5, fontSize: 9.5, fontWeight: 600,
                      background: 'rgba(245,158,11,.12)', color: '#d97706',
                      border: '1px solid rgba(245,158,11,.3)',
                      borderRadius: 3, padding: '1px 5px', verticalAlign: 'middle',
                    }}>估算</span>
                  )}
                </span>
                <span><span className="mono" style={{ color: 'var(--text-2)' }}>{meta.frequency}</span> Hz</span>
                {time.length > 0 && (
                  <span><span className="mono" style={{ color: 'var(--text-2)' }}>{time.length.toLocaleString()}</span> samples ({time[time.length - 1].toFixed(1)}s)</span>
                )}
                {meta.isMARSSummary && (
                  <span style={{ fontSize: 9.5, fontWeight: 600, background: 'rgba(99,102,241,.10)', color: '#6366f1', border: '1px solid rgba(99,102,241,.3)', borderRadius: 3, padding: '1px 6px' }}>MARS 汇总导入</span>
                )}
                <span style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 8px' }}>
                  {jumps.length} trial{jumps.length > 1 ? 's' : ''}
                </span>
              </div>

              {/* Save-to-athlete banner (only shown when onSaveSession is provided) */}
              {onSaveSession && athletes.length > 0 && (
                <div data-se1-save-banner style={{
                  display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
                  background: saveStatus === 'saved' ? 'rgba(52,211,153,.07)' : 'var(--panel)',
                  border: `1px solid ${saveStatus === 'saved' ? 'rgba(52,211,153,.3)' : 'var(--border)'}`,
                  borderTop: saveStatus === 'saved' ? '1px solid rgba(52,211,153,.3)' : '2px solid var(--accent)',
                  borderRadius: 8, padding: '9px 14px',
                }}>
                  <span style={{ fontSize: 11, color: 'var(--muted)', whiteSpace: 'nowrap' }}>保存至运动员档案</span>
                  <select
                    value={saveAthleteId || ''}
                    onChange={e => { setSaveAthleteId(e.target.value); setSaveStatus(null); }}
                    style={{
                      flex: 1, minWidth: 120, maxWidth: 200, fontSize: 11,
                      background: 'var(--panel-2)', border: '1px solid var(--border)',
                      borderRadius: 5, color: 'var(--text)', padding: '4px 8px',
                      fontFamily: 'var(--font-sans)',
                    }}
                  >
                    <option value="">— 选择运动员 —</option>
                    {athletes.map(a => (
                      <option key={a.id} value={a.id}>{a.name}</option>
                    ))}
                  </select>
                  <span style={{ fontSize: 11, color: 'var(--muted)', whiteSpace: 'nowrap' }}>日期</span>
                  <input type="date" value={saveDate || ''} onChange={e => { setSaveDate(e.target.value); setSaveStatus(null); }}
                    style={{ fontSize: 11, background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 5, color: 'var(--text)', padding: '3px 6px' }} />
                  {saveStatus === 'saved' ? (
                    <span style={{ fontSize: 11, color: 'rgba(52,211,153,.9)', whiteSpace: 'nowrap' }}>✓ 已保存</span>
                  ) : (
                    <>
                      <button onClick={handleSave} disabled={!saveAthleteId || saving} style={{
                        fontSize: 11, padding: '4px 14px', borderRadius: 5, cursor: (saveAthleteId && !saving) ? 'pointer' : 'not-allowed',
                        background: (saveAthleteId && !saving) ? 'var(--accent)' : 'var(--panel-2)',
                        border: '1px solid ' + ((saveAthleteId && !saving) ? 'var(--accent)' : 'var(--border)'),
                        color: (saveAthleteId && !saving) ? '#fff' : 'var(--muted)',
                        fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap',
                      }}>{saving ? '保存中…' : '保存'}</button>
                    </>
                  )}
                  <div style={{ flexBasis: '100%' }}>
                    <ForceNote>点击下方 Jump 大卡选择代表 Trial，再单独确认保存。{saveSourceCsv ? '本次会同时保存原始 CSV。' : '本次不保存原始 CSV，之后无法从该会话重新分析。'}</ForceNote>
                  </div>
                </div>
              )}

              {/* Jump selector */}
              <JumpSelector jumps={jumps} selectedIdx={selectedIdx} representativeIdx={representativeIdx}
                onSelect={idx => { setSelectedIdx(idx); setRepresentativeIdx(idx); setRepUserChanged(true); setSaveStatus(null); }}
                classifications={classifications} />

              {/* Raw force-time chart (hidden for MARS summary imports) */}
              {phases ? (
                <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px' }}>
                  <CMJChart time={time} left={left} right={right} total={total}
                    velAt={velAt} dispAt={dispAt} phases={phases}
                    bw_n={meta.weight * G} mass_kg={meta.weight}
                    overlays={overlays} normalizeX={false}
                    classResult={classResult} />
                </div>
              ) : (
                <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '18px 16px', color: 'var(--muted)', fontSize: 12, textAlign: 'center' }}>
                  MARS 汇总导入 · 无原始力-时间数据，仅显示指标
                </div>
              )}

              {/* Chart controls */}
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
                {[
                  { key: 'vel',   label: 'Velocity',     color: 'rgba(167,139,250,.9)' },
                  { key: 'disp',  label: 'Displacement', color: 'rgba(52,211,153,.9)'  },
                  { key: 'acc',   label: 'Acceleration', color: 'rgba(251,191,36,.9)'  },
                  { key: 'power', label: 'Power',        color: 'rgba(248,113,113,.9)' },
                ].map(({ key, label, color }) => toggleBtn(label, overlays[key], () => toggleOverlay(key), color, key))}
                <div style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
                {toggleBtn('Normalized (0–100%)', normalizeX, () => setNormalizeX(v => !v))}
                {toggleBtn('F–D / F–V Loops', showLoops, () => setShowLoops(v => !v))}
                <div style={{ width: 1, height: 16, background: 'var(--border)', margin: '0 4px' }} />
                <span title="Owen 2014 / Hawkin: 起跳点回推 30ms 至力值偏离前。关闭则与 VALD ForceDecks 报告对齐。">
                  {toggleBtn(
                    useOnsetBackshift ? 'Onset −30ms (Owen 2014)' : 'Onset @ 5SD (VALD-style)',
                    useOnsetBackshift,
                    toggleOnsetBackshift,
                    'rgba(99,179,237,.9)'
                  )}
                </span>
              </div>

              {/* ── CMJ Classification ─────────────────────────────── */}
              {classifications && classResult && (() => {
                const info = CMJ_TYPE_INFO[classResult.type];
                return (
                  <>
                    {/* All-jump type summary row */}
                    {jumps.length > 1 && (
                      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
                        <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em', marginRight: 2 }}>分型</span>
                        {jumps.map((j, idx) => {
                          const cl = classifications[idx];
                          const isActive = idx === selectedIdx;
                          return (
                            <button key={idx} onClick={() => setSelectedIdx(idx)} style={{
                              display: 'inline-flex', alignItems: 'center', gap: 5,
                              padding: '3px 9px', borderRadius: 5, cursor: 'pointer', fontSize: 10.5,
                              background: isActive ? 'rgba(15,23,42,.07)' : 'transparent',
                              border: `1px solid ${isActive ? (cl.isLF1 ? 'rgba(52,211,153,.5)' : 'rgba(251,191,36,.45)') : 'var(--border)'}`,
                              color: isActive ? (cl.isLF1 ? 'rgba(52,211,153,.95)' : 'rgba(251,191,36,.9)') : 'var(--muted)',
                              fontFamily: 'var(--font-sans)',
                            }}>
                              <span style={{ fontWeight: 700 }}>Type {cl.type}</span>
                              <span style={{ opacity: .6 }}>· J{j.index}</span>
                            </button>
                          );
                        })}
                      </div>
                    )}

                    {/* Current jump classification detail card */}
                    <div style={{
                      background: 'var(--panel)', border: `1px solid ${classResult.isLF1 ? 'rgba(52,211,153,.22)' : 'rgba(251,191,36,.22)'}`,
                      borderRadius: 10, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 9,
                    }}>
                      {/* Header row */}
                      <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
                        <span className="mono" style={{
                          fontSize: 26, fontWeight: 700, lineHeight: 1,
                          color: classResult.isLF1 ? 'rgba(52,211,153,.95)' : 'rgba(251,191,36,.9)',
                        }}>Type {classResult.type}</span>
                        <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
                          <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>{info.label}</span>
                          <span style={{ fontSize: 11, color: 'var(--muted)' }}>{info.brief}</span>
                        </div>
                        {/* Bimodal auxiliary metrics */}
                        {classResult.isBimodal && (
                          <div style={{ marginLeft: 'auto', display: 'flex', gap: 16, fontSize: 11, flexShrink: 0 }}>
                            <span style={{ color: 'var(--muted)' }}>谷深
                              <span className="mono" style={{ color: 'var(--text-2)', marginLeft: 4 }}>
                                {classResult.valleyDepthPct != null ? classResult.valleyDepthPct.toFixed(1) + '%' : '—'}
                              </span>
                            </span>
                            <span style={{ color: 'var(--muted)' }}>峰间距
                              <span className="mono" style={{ color: 'var(--text-2)', marginLeft: 4 }}>
                                {classResult.peakIntervalMs != null ? classResult.peakIntervalMs.toFixed(0) + ' ms' : '—'}
                              </span>
                            </span>
                          </div>
                        )}
                      </div>
                      {/* Mechanism */}
                      <div style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.6 }}>{info.mechanism}</div>
                      {/* Interpretation boundary */}
                      <div style={{
                        borderTop: '1px solid var(--border)', paddingTop: 9,
                        fontSize: 11, color: 'var(--muted-2)', lineHeight: 1.6,
                      }}>
                        <span style={{ color: 'var(--muted)', fontWeight: 600 }}>解读边界 · </span>
                        {CMJ_CLASSIFICATION_DISCLAIMER}
                      </div>
                    </div>
                  </>
                );
              })()}

              {/* Normalized chart — multi-trial comparison */}
              {normalizeX && (
                <>
                  {/* Trial selector */}
                  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
                    <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em', marginRight: 4 }}>Compare</span>
                    {jumps.map((j, idx) => {
                      const sel = compareSelected.has(idx);
                      const col = NORM_COLORS[
                        Array.from(compareSelected).filter(i => i <= idx).length - 1
                      ] || NORM_COLORS[0];
                      return (
                        <button key={idx} onClick={() => toggleCompare(idx)} style={{
                          display: 'inline-flex', alignItems: 'center', gap: 6,
                          padding: '4px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                          background: sel ? 'rgba(15,23,42,.06)' : 'transparent',
                          border: `1px solid ${sel ? col.line : 'var(--border)'}`,
                          color: sel ? col.line : 'var(--muted)',
                          fontFamily: 'var(--font-sans)',
                        }}>
                          <span style={{ width: 8, height: 8, borderRadius: '50%', background: sel ? col.line : 'var(--muted-2)', flexShrink: 0 }} />
                          Jump {j.index} · {j.metrics.jumpHeight}cm
                        </button>
                      );
                    })}
                  </div>
                  <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px' }}>
                    <CMJNormChart jumps={jumps} compareSelected={compareSelected}
                      total={total} left={left} right={right}
                      bw_n={meta.weight * G} mass_kg={meta.weight}
                      overlays={overlays} classifications={classifications} />
                  </div>
                </>
              )}

              {/* ── F–D / F–V Loops ────────────────────────────────── */}
              {showLoops && (
                <>
                  {/* Movement duration — all selected (compareSelected) jumps */}
                  <div style={{
                    background: 'var(--panel)', border: '1px solid var(--border)',
                    borderRadius: 8, padding: '10px 16px', display: 'flex', flexDirection: 'column', gap: 8,
                  }}>
                    <span style={{ fontSize: 9.5, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em' }}>总动作时长 (a→g)</span>
                    {Array.from(compareSelected).sort((a, b) => a - b).map(idx => {
                      const jj = jumps[idx];
                      const jm = jj.metrics;
                      const nc = NORM_COLORS[Array.from(compareSelected).sort((a,b)=>a-b).indexOf(idx) % NORM_COLORS.length];
                      return (
                        <div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
                          <span style={{ fontSize: 10, color: nc.line, fontWeight: 600, width: 54, flexShrink: 0 }}>
                            Jump {jj.index}
                          </span>
                          <span className="mono" style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', width: 56, flexShrink: 0 }}>
                            {jm.ttt} s
                          </span>
                          <div style={{ width: 1, height: 20, background: 'var(--border)', flexShrink: 0 }} />
                          {[
                            { label: '卸载', val: jm.unweightingTime, pct: jm.ttoPct.unweight, col: 'rgba(99,179,237,.9)' },
                            { label: '制动', val: jm.brakingTime,     pct: jm.ttoPct.braking,  col: 'rgba(251,146,60,.9)' },
                            { label: '推进', val: jm.propulsiveTime,  pct: jm.ttoPct.prop,     col: 'rgba(52,211,153,.9)' },
                          ].map(({ label, val, pct, col }) => (
                            <span key={label} style={{ fontSize: 11, color: 'var(--muted-2)' }}>
                              <span style={{ color: col, fontWeight: 600 }}>{label}</span>{' '}
                              <span className="mono" style={{ color: 'var(--text-2)' }}>{pct.toFixed(0)}%</span>
                              <span style={{ color: 'var(--muted)', marginLeft: 3 }}>{val}s</span>
                            </span>
                          ))}
                        </div>
                      );
                    })}
                  </div>

                  {/* Shared compare selector (also used by normalized chart if open) */}
                  {!normalizeX && (
                    <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
                      <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em', marginRight: 4 }}>Compare</span>
                      {jumps.map((j, idx) => {
                        const sel = compareSelected.has(idx);
                        const col = NORM_COLORS[Array.from(compareSelected).filter(i => i <= idx).length - 1] || NORM_COLORS[0];
                        return (
                          <button key={idx} onClick={() => toggleCompare(idx)} style={{
                            display: 'inline-flex', alignItems: 'center', gap: 6,
                            padding: '4px 10px', borderRadius: 6, cursor: 'pointer', fontSize: 11,
                            background: sel ? 'rgba(15,23,42,.06)' : 'transparent',
                            border: `1px solid ${sel ? col.line : 'var(--border)'}`,
                            color: sel ? col.line : 'var(--muted)', fontFamily: 'var(--font-sans)',
                          }}>
                            <span style={{ width: 8, height: 8, borderRadius: '50%', background: sel ? col.line : 'var(--muted-2)', flexShrink: 0 }} />
                            Jump {j.index} · {j.metrics.jumpHeight}cm
                          </button>
                        );
                      })}
                    </div>
                  )}
                  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                    <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 4px 4px' }}>
                      <LoopChart mode="fd" jumps={jumps} compareSelected={compareSelected}
                        total={total} bw_n={meta.weight * G} />
                    </div>
                    <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 4px 4px' }}>
                      <LoopChart mode="fv" jumps={jumps} compareSelected={compareSelected}
                        total={total} bw_n={meta.weight * G} />
                    </div>
                  </div>
                </>
              )}

              {/* ── Trial Comparison Table ─────────────────────────── */}
              <ComparisonTable
                jumps={jumps}
                selectedMetricKeys={selectedMetricKeys}
                onToggleMetric={toggleMetric}
                onResetMetrics={resetMetrics}
                classifications={classifications}
                selectedIdx={selectedIdx}
                onSelectJump={setSelectedIdx}
                metricsView={metricsView}
              />

              {/* ── Selected Jump Detail ───────────────────────────── */}
              {/* FL-T1: presentation form toggle — cards ⇄ table (same selected-trial */}
              {/* metrics, one form at a time). The table form is rendered inside */}
              {/* ComparisonTable's PART A; the card form is the MCard grids below. */}
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
                <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em' }}>
                  Jump {jump.index} — 详细指标
                </div>
                <div style={{ display: 'inline-flex', background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 8, padding: 2, gap: 2 }}
                  role="tablist" aria-label="指标呈现形式">
                  {[['cards', '卡片'], ['table', '表格']].map(([v, lab]) => {
                    const on = metricsView === v;
                    return (
                      <button key={v} type="button" role="tab" aria-selected={on}
                        onClick={() => updateMetricsView(v)}
                        style={{ padding: '4px 12px', fontSize: 11, fontWeight: 600, cursor: 'pointer', border: 0, borderRadius: 6, fontFamily: 'var(--font-sans)', background: on ? 'var(--panel)' : 'transparent', color: on ? 'var(--text)' : 'var(--muted)', boxShadow: on ? '0 1px 2px rgba(20,22,28,.08)' : 'none' }}>
                        {lab}
                      </button>
                    );
                  })}
                </div>
                <div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
              </div>

              {/* ── Output ─────────────────────────────────────────── */}
              {metricsView === 'cards' && (<>
              {/* P2-A: 证据优先排序 — mRSI 最灵敏监控指标(Gathercole 2015) → 相对峰值功率 → TTT → JH */}
              {section('Output')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(140px,1fr))', gap: 8 }}>
                <MCard cn="反应力指数(Mod)" label="RSI-mod" value={m.rsiMod} unit="" sub="JH / TTT" accent="var(--accent)"
                  info={{ formula: 'RSI-mod = JH (m) / TTT (s)', cite: 'Gathercole 2015; Kipp 2019 — 最灵敏的疲劳监控指标' }} />
                {/* P2A-2: 峰值功率 — 显示 W/kg，标注 ↓≥150W 绝对值预警阈值（Washington Nationals 2023-24, n=55: 73% 准确率） */}
                {m.relPeakPower != null && <MCard cn="相对峰值功率" label="Rel Peak Power" value={m.relPeakPower} unit="W/kg" sub={'↓≥150W → ⚠ L3 预警' + (m.peakPower != null ? ' · ' + m.peakPower + 'W' : '')} accent="rgba(248,113,113,.9)"
                  info={{ formula: 'P_rel = F × v / m\npeak instantaneous in propulsive phase', cite: 'McMahon 2018; Washington Nationals (2023–24, n=55): ↓≥150W 绝对值预测表现下降准确率 73%' }} />}
                <MCard cn="起跳时间（动作时长）" label="Time to Takeoff" value={m.ttt} unit="s"
                  info={{ formula: 'TTT = t_takeoff − t_onset\n(onset = force < 5×SD below BW)', cite: 'VALD ForceDecks V2.0' }} />
                <MCard cn="跳跃高度（冲量）" label="Jump Height" value={m.jumpHeight} unit="cm" sub="Impulse method" accent="var(--pos)"
                  info={{ formula: 'h = v²/(2g)\nv = ∫(F−BW)/m dt', cite: 'Linthorne 2001; McMahon 2018' }} />
                {m.jumpHeightFT != null && <MCard cn="跳跃高度(飞行时间)" label="Jump Height (FT)" value={m.jumpHeightFT} unit="cm" sub="Flight-time method"
                  info={{ formula: 'h = g × (t_flight/2)²\n/ 2', cite: 'VALD ForceDecks V2.0' }} />}
                {m.flightTime != null && <MCard cn="腾空时间" label="Flight Time" value={m.flightTime} unit="s"
                  info={{ formula: 'Time from takeoff to landing\n(GRF crosses zero twice)', cite: 'VALD ForceDecks V2.0' }} />}
                <MCard cn="起跳速度" label="Takeoff Velocity" value={m.takeoffVelocity} unit="m/s" accent="var(--pos)"
                  info={{ formula: 'v_to = ∫(F−BW)/m dt\nfrom onset to last ground contact', cite: 'VALD ForceDecks V2.0; McMahon 2018' }} />
                {m.epv != null && <MCard cn="离心峰值速度" label="Eccentric Peak Vel" value={m.epv} unit="m/s" sub="Max downward velocity" accent="rgba(99,179,237,.9)"
                  info={{ formula: 'EPV = max(−v) during braking\n= peak downward CM velocity', cite: 'VALD ForceDecks V2.0; Gathercole 2015' }} />}
                <MCard cn="质心下降深度" label="CM Depth" value={m.cmDepth} unit="cm"
                  info={{ formula: 'CM Depth = ∫∫(F−BW)/m dt²\nmax downward displacement', cite: 'VALD ForceDecks V2.0' }} />
                <MCard cn="峰值加速度" label="Peak Acceleration" value={m.peakAcc} unit="m/s²" sub="Propulsive phase" accent="rgba(251,191,36,.9)"
                  info={{ formula: 'a_peak = (F − BW) / m\nmax over propulsive phase', cite: 'VALD ForceDecks V2.0' }} />
              </div>

              {/* ── Phase Time ─────────────────────────────────────── */}
              {section('相位时长分布 Phase Time Distribution')}
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
                <PhaseBar cn={`卸载 (${m.unweightingTime}s)`} label={`Unweighting (${m.unweightingTime}s)`} pct={m.ttoPct.unweight} color="rgba(99,179,237,.65)" />
                <PhaseBar cn={`制动 (${m.brakingTime}s)`}     label={`Braking (${m.brakingTime}s)`}         pct={m.ttoPct.braking}  color="rgba(251,146,60,.65)" />
                <PhaseBar cn={`推进 (${m.propulsiveTime}s)`}  label={`Propulsive (${m.propulsiveTime}s)`}   pct={m.ttoPct.prop}     color="rgba(52,211,153,.65)" />
              </div>

              {/* ── Force ──────────────────────────────────────────── */}
              {section('相位参数—力值 Force by Phase')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(130px,1fr))', gap: 6 }}>
                <MCardSm cn="平均卸载力"    label="Avg Unweight F"   value={m.avgUnweightForce}    unit="N" />
                <MCardSm cn="峰值卸载力"    label="Peak Unweight F"  value={m.peakUnweightForce}   unit="N" />
                <MCardSm cn="平均卸载力 %BW"  label="Avg Unweight %BW" value={m.relAvgUnweightForce} unit="%" />
                <MCardSm cn="峰值卸载 %BW"  label="Peak Unweight %BW" value={m.relPeakUnweightForce} unit="%" />
                <MCardSm cn="平均制动力"    label="Avg Braking F"    value={m.avgBrakingForce}     unit="N" />
                <MCardSm cn="峰值制动力"    label="Peak Braking F"   value={m.peakBrakingForce}    unit="N" />
                <MCardSm cn="平均制动 %BW"  label="Avg Braking %BW"  value={m.relAvgBrakingForce}  unit="%" />
                <MCardSm cn="峰值制动 %BW"  label="Peak Braking %BW" value={m.relPeakBrakingForce} unit="%" />
                <MCardSm cn="平均推进力"    label="Avg Prop F"       value={m.avgPropForce}        unit="N" />
                <MCardSm cn="峰值推进力"    label="Peak Prop F"      value={m.peakPropForce}       unit="N" accent="var(--pos)" />
                <MCardSm cn="平均推进 %BW"  label="Avg Prop %BW"     value={m.relAvgPropForce}     unit="%" />
                <MCardSm cn="峰值推进 %BW"  label="Peak Prop %BW"    value={m.relPeakPropForce}    unit="%" accent="var(--pos)" />
              </div>

              {/* ── RFD ────────────────────────────────────────────── */}
              {section('发力率 Rate of Force Development', 'from propulsive onset (velocity zero-crossing)')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(120px,1fr))', gap: 6 }}>
                <MCardSm cn="制动 RFD"     label="Braking RFD" value={m.brakingRFD} unit="N/s"
                  info={{ formula: 'Braking RFD = ΔF/Δt\nfrom min-vel to zero-crossing', cite: 'McMahon 2018; VALD V2.0' }} />
                {m.peakRFD != null && <MCardSm cn="峰值瞬时 RFD" label="Peak RFD" value={m.peakRFD} unit="N/s" accent="rgba(248,113,113,.9)"
                  info={{ formula: 'max(ΔF/Δt), 10ms rolling window\nover propulsive phase', cite: 'Haff & Triplett 2015' }} />}
                <MCardSm cn="RFD 0–50ms"   label="RFD 0–50ms"  value={m.rfd50}  unit="N/s"
                  info={{ formula: 'RFD = (F_50 − F_onset) / 0.05s\ncumulative from zero-crossing', cite: 'Haff & Triplett 2015' }} />
                <MCardSm cn="RFD 0–100ms"  label="RFD 0–100ms" value={m.rfd100} unit="N/s"
                  info={{ formula: 'RFD = (F_100 − F_onset) / 0.1s\ncumulative from zero-crossing', cite: 'Haff & Triplett 2015' }} />
                <MCardSm cn="RFD 0–150ms"  label="RFD 0–150ms" value={m.rfd150} unit="N/s"
                  info={{ formula: 'RFD = (F_150 − F_onset) / 0.15s\ncumulative from zero-crossing', cite: 'Haff & Triplett 2015' }} />
                <MCardSm cn="RFD 0–200ms"  label="RFD 0–200ms" value={m.rfd200} unit="N/s"
                  info={{ formula: 'RFD = (F_200 − F_onset) / 0.2s\ncumulative from zero-crossing', cite: 'Haff & Triplett 2015' }} />
                <MCardSm cn="RFD 0–250ms"  label="RFD 0–250ms" value={m.rfd250} unit="N/s"
                  info={{ formula: 'RFD = (F_250 − F_onset) / 0.25s\ncumulative from zero-crossing', cite: 'Haff & Triplett 2015' }} />
              </div>

              {/* ── Power ──────────────────────────────────────────── */}
              {section('功率 Power')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(130px,1fr))', gap: 6 }}>
                <MCardSm cn="峰值推进功率"    label="Peak Prop Pwr"   value={m.peakPower}       unit="W"    accent="rgba(248,113,113,.9)" />
                <MCardSm cn="平均推进功率"    label="Avg Prop Pwr"    value={m.avgPropPower}    unit="W" />
                <MCardSm cn="峰值制动功率"    label="Peak Brak Pwr"   value={m.peakBrakingPower} unit="W" />
                <MCardSm cn="平均制动功率"    label="Avg Brak Pwr"    value={m.avgBrakingPower} unit="W" />
                <MCardSm cn="相对峰推 W/kg"   label="Rel Peak Prop"   value={m.relPeakPower}    unit="W/kg" accent="rgba(248,113,113,.9)" />
                <MCardSm cn="相对均推 W/kg"   label="Rel Avg Prop"    value={m.relAvgPropPower} unit="W/kg" />
                <MCardSm cn="相对峰制 W/kg"   label="Rel Peak Brak"   value={m.relPeakBrakPower} unit="W/kg" />
                <MCardSm cn="相对均制 W/kg"   label="Rel Avg Brak"    value={m.relAvgBrakPower} unit="W/kg" />
              </div>

              {/* ── Impulse ────────────────────────────────────────── */}
              {section('冲量 Impulse')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(130px,1fr))', gap: 6 }}>
                <MCardSm cn="卸载冲量"         label="Unweight Imp"     value={m.unweightingImpulse}    unit="N·s" />
                <MCardSm cn="卸载净冲量"       label="Unweight Net Imp" value={m.unweightNetImpulse}    unit="N·s" />
                <MCardSm cn="相对卸载净冲量"   label="Rel Unweight Net" value={m.relUnweightNetImpulse} unit="N·s/kg" />
                <MCardSm cn="制动冲量"         label="Braking Imp"      value={m.brakingImpulse}        unit="N·s" />
                <MCardSm cn="制动净冲量"       label="Braking Net Imp"  value={m.brakingNetImpulse}     unit="N·s" />
                <MCardSm cn="相对制动净冲量"   label="Rel Brak Net Imp" value={m.relBrakingNetImpulse}  unit="N·s/kg" />
                <MCardSm cn="推进冲量"         label="Prop Imp"         value={m.propImpulse}           unit="N·s" />
                <MCardSm cn="推进净冲量"       label="Prop Net Imp"     value={m.propNetImpulse}        unit="N·s" accent="var(--pos)" />
                <MCardSm cn="相对推进净冲量"   label="Rel Prop Net Imp" value={m.relPropNetImpulse}     unit="N·s/kg" accent="var(--pos)" />
                <MCardSm cn="冲量比"           label="Impulse Ratio"    value={m.impulseRatio}          unit="" accent="var(--accent)"
                  info={{ formula: 'Braking Net Imp / Prop Net Imp\n(ratios <1 = prop dominant)', cite: 'VALD ForceDecks V2.0; McMahon 2018' }} />
                {m.p1p2Ratio != null && <MCardSm cn="P1/P2 比" label="P1/P2 Ratio" value={m.p1p2Ratio} unit=""
                  info={{ formula: 'P1 = impulse in first half of propulsion\nP2 = impulse in second half\nP1/P2 < 1 = late-peaking strategy', cite: 'VALD ForceDecks V2.0; Gathercole 2015' }} />}
              </div>

              {/* ── Mechanics ──────────────────────────────────────── */}
              {section('机制指标 Mechanics')}
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(140px,1fr))', gap: 8 }}>
                <MCard cn="动态刚度"      label="Dynamic Stiffness" value={m.stiffness} unit="N/m" sub="Peak prop F / CM depth" accent="var(--accent)" />
                <MCard cn="弹簧样相关性"  label="Spring-Like Corr"  value={m.springCorr} unit="" sub="Pearson r (GRF vs disp)" accent={
                  m.springCorr != null
                    ? (Math.abs(m.springCorr) > 0.9 ? 'var(--pos)' : Math.abs(m.springCorr) > 0.7 ? 'var(--warn)' : 'var(--neg)')
                    : undefined
                } />
              </div>

              {/* ── Asymmetry ──────────────────────────────────────── */}
              {section('对称性 L / R Asymmetry  (positive = Left dominant)')}
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
                <AsymBar cn="制动力不对称"   label="Braking Force"    value={m.asymBraking} />
                <AsymBar cn="推进力不对称"   label="Propulsive Force" value={m.asymProp}    />
                <AsymBar cn="制动冲量不对称" label="Braking Impulse"  value={m.asymBrakImpulse} />
                <AsymBar cn="推进冲量不对称" label="Prop Impulse"     value={m.asymPropImpulse} />
                <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 2 }}>
                  正值 = 左侧主导 · 绿 &lt;10% · 黄 10–15% · 红 &gt;15%
                </div>
              </div>

              {/* ── Landing ────────────────────────────────────────── */}
              {(m.timeToStab != null || m.landingPI != null) && (
                <>
                  {section('落地参数 Landing')}
                  <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(140px,1fr))', gap: 8 }}>
                    {m.timeToStab != null && <MCard cn="稳定时间" label="Time to Stabilization" value={m.timeToStab} unit="s" sub="GRF ±5%BW for 1s" />}
                    {m.landingPI  != null && <MCard cn="着陆表现指数" label="Landing Perf Index" value={m.landingPI}  unit="" sub="JH / TTS" accent="var(--accent)" />}
                  </div>
                </>
              )}
              </>)}
              {/* FL-T1: end cards-view region */}
            </>
          );
        })()}
      </div>
    );
  }

  // ── SESSION PERSISTENCE HELPERS ──────────────────────────────────────────

  // Downsample a force-time slice to n points for compact storage.
  // tArr: raw time array, fArr: force/BW array, n: target points
  // Returns { t: [0..1 normalized], f: [...] }
  function downsampleCurve(tArr, fArr, n) {
    const len = tArr.length;
    if (len === 0) return { t: [], f: [] };
    const t0 = tArr[0], t1 = tArr[len - 1], tSpan = t1 - t0 || 1;
    if (len <= n) {
      return { t: tArr.map(t => (t - t0) / tSpan), f: [...fArr] };
    }
    const tOut = [], fOut = [];
    for (let i = 0; i < n; i++) {
      const idx = Math.round(i * (len - 1) / (n - 1));
      tOut.push((tArr[idx] - t0) / tSpan);
      fOut.push(+fArr[idx].toFixed(4));
    }
    return { t: tOut, f: fOut };
  }

  // Build a compact, storable session object from the current analysis result.
  // curve.t : normalised time 0→1 (onset to takeoff)
  // curve.f : GRF / BW (unitless ratio)
  // curve.d : CM displacement in metres, zero-centred at onset (negative = downward)
  // curve.v : CM velocity in m/s
  // keyPts  : { a, b, c, d, e, f, g } — 7 key-point time fractions (0→1) for plotting:
  //           a=onset(0), b=peak-unloading (minForce), c=peak-neg-velocity (minVel),
  //           d=peak propulsive force, e=zero-cross, f=peak velocity, g=takeoff(1)
  const copyCMJPersistedMetricScalars = (source) => {
    const metrics = {};
    Object.keys(source || {}).forEach(key => {
      // Agreement deltas are capture-quality evidence, not performance metrics.
      if (key === 'jumpHeightDeltaCm' || key === 'jumpHeightDeltaPct') return;
      const value = source[key];
      if (typeof value === 'number' && Number.isFinite(value)) metrics[key] = value;
    });
    return metrics;
  };

  function buildCMJSession(result, fileName, representativeIdx = 0, useOnsetBackshift = true, repUserChanged = false, overrideDate = null) {
    const { meta, time, total, jumps, classifications } = result;
    const bw_n = meta.weight * G;
    const id   = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    const date = overrideDate || meta.date || new Date().toISOString().slice(0, 10);

    const CURVE_PTS = 200;

    const trials = jumps.map((j, i) => {
      const cl = classifications[i];

      // MARS summary imports have no raw data — store metrics only, no curve
      if (!j.phases) {
        const m = j.metrics;
        const metrics = copyCMJPersistedMetricScalars(m);
        return { index: j.index, type: cl.type, isBimodal: false, isLF1: false,
                 metrics, curve: null, keyPts: null, detection: j.detection || null,
                 tMinVelPct: null, tZeroCrossPct: null };
      }

      const { onset, minForce, minVel, zeroCross, takeoff } = j.phases;
      const { disp, vel, quietRef } = j;

      // Full movement: onset → takeoff
      const segStart = onset, segEnd = takeoff;
      const segLen   = segEnd - segStart + 1;
      const tSlice   = time.slice(segStart, segEnd + 1);
      const t0 = tSlice[0], tSpan = (tSlice[segLen - 1] - t0) || 1;

      // force / BW
      const fSlice = total.slice(segStart, segEnd + 1).map(v => v / bw_n);

      // displacement in metres, zero-centred at onset
      const dSlice = Array.from({ length: segLen }, (_, k) => {
        const di = (segStart + k) - quietRef;
        return (di >= 0 && di < disp.length) ? disp[di] : 0;
      });
      const d0 = dSlice[0];
      const dCentered = dSlice.map(d => d - d0);

      // velocity in m/s
      const vSlice = Array.from({ length: segLen }, (_, k) => {
        const vi = (segStart + k) - quietRef;
        return (vi >= 0 && vi < vel.length) ? vel[vi] : 0;
      });

      // ── Key-point indices (in global frame) — computed from raw signals,
      // not downsampled, so the labels stay precise even after compaction ──
      // d: peak propulsive force in [zeroCross, takeoff]
      let peakFIdx = zeroCross;
      for (let k = zeroCross + 1; k <= takeoff; k++)
        if (total[k] > total[peakFIdx]) peakFIdx = k;
      // f: peak velocity in [zeroCross, takeoff]
      const velAtIdx = idx => {
        const k = idx - quietRef;
        return (k >= 0 && k < vel.length) ? vel[k] : 0;
      };
      let peakVIdx = zeroCross;
      for (let k = zeroCross + 1; k <= takeoff; k++)
        if (velAtIdx(k) > velAtIdx(peakVIdx)) peakVIdx = k;

      const frac = idx => Math.max(0, Math.min(1, (time[idx] - t0) / tSpan));
      const keyPts = {
        a: 0,
        b: frac(minForce),
        c: frac(minVel),
        d: frac(peakFIdx),
        e: frac(zeroCross),
        f: frac(peakVIdx),
        g: 1,
      };

      // Downsample all four signals together (uniform index stride)
      const tOut = [], fOut = [], dOut = [], vOut = [];
      for (let si = 0; si < CURVE_PTS; si++) {
        const idx = segLen <= CURVE_PTS
          ? si
          : Math.round(si * (segLen - 1) / (CURVE_PTS - 1));
        if (idx >= segLen) break;
        tOut.push(+((tSlice[idx] - t0) / tSpan).toFixed(4));
        fOut.push(+fSlice[idx].toFixed(4));
        dOut.push(+dCentered[idx].toFixed(4));
        vOut.push(+vSlice[idx].toFixed(4));
      }
      const curve = { t: tOut, f: fOut, d: dOut, v: vOut };

      // Scalar metrics
      const m = j.metrics;
      const metrics = copyCMJPersistedMetricScalars(m);

      return { index: j.index, type: cl.type, isBimodal: cl.isBimodal, isLF1: cl.isLF1,
               metrics, curve, keyPts, detection: j.detection || null,
               // Quality data persisted OUTSIDE the summary-metric list (FORCE-SCIENCE M1 audit):
               // continuous flight-time vs impulse JH agreement, saved so it survives a reload
               // (it is not a display metric, so it stays out of ALL_SUMMARY_METRICS).
               quality: { jumpHeightDeltaCm: m.jumpHeightDeltaCm ?? null, jumpHeightDeltaPct: m.jumpHeightDeltaPct ?? null },
               // Kept for backward compat with already-saved sessions
               tMinVelPct: keyPts.c, tZeroCrossPct: keyPts.e };
    });

    // Session-level best / mean
    const best = {}, mean = {};
    const metricDirections = new Map(ALL_SUMMARY_METRICS.map(({ key, better }) => [key, better]));
    const metricKeys = new Set();
    trials.forEach(trial => Object.keys(trial.metrics || {}).forEach(key => metricKeys.add(key)));
    metricKeys.forEach(key => {
      const better = metricDirections.get(key);
      const vals = trials.map(t => t.metrics[key]).filter(v => v != null && isFinite(v));
      if (!vals.length) return;
      mean[key] = vals.reduce((a, b) => a + b, 0) / vals.length;
      if      (better === 'higher') best[key] = Math.max(...vals);
      else if (better === 'lower')  best[key] = Math.min(...vals);
      else                          best[key] = mean[key];
    });

    const repIndex = Math.min(Math.max(0, representativeIdx || 0), trials.length - 1);
    const repTrial = trials[repIndex] || trials[0];
    const representative = {
      index: repTrial?.index ?? repIndex + 1,
      source: repUserChanged ? 'manual' : 'auto',
      metrics: repTrial?.metrics ? { ...repTrial.metrics } : {},
    };

    return {
      id, date, fileName, jumpCount: jumps.length, trials,
      best: representative.metrics,
      autoBest: best,
      mean,
      representative,
      profileSource: { type: 'cmj', trialIndex: representative.index, mode: repUserChanged ? 'manual' : 'auto' },
      provenance: {
        source: 'force_plate_upload',
        testType: 'cmj',
        fileName,
        savedAt: new Date().toISOString(),
        detectionModes: Array.from(new Set(trials.map(t => t.detection?.mode || 'unknown'))),
        ...(meta._intake ? { intake: { ...meta._intake } } : {}),
      },
      // Algorithm version governance (FORCE-SCIENCE M1 §2.6): new sessions record which
      // algorithm produced them so longitudinal analysis can flag cross-version comparisons;
      // old sessions are NEVER recomputed. version bumps whenever detection/metric logic changes.
      algorithmRef: (() => {
        // One shared SamplingProfile (GPT M1 audit-fix P1): MARS summaries (time: []) → external-
        // precomputed with unknown filter/onset — NOT a false "low sample rate"; raw files get the
        // effective (measured) rate + the filter that actually ran.
        const sampling = (typeof window !== 'undefined' && window.ForceCoreSampling)
          ? window.ForceCoreSampling.samplingProfile(time, meta.frequency) : null;
        const external = !!sampling && sampling.source === 'external-precomputed';
        return {
          testType: 'cmj',
          version: external
            ? 'external-precomputed'
            : sampling?.status === 'non-uniform' ? 'cmj-2-nonuniform-median' : 'cmj-2',
          onsetPolicy: external ? 'external-unknown' : (useOnsetBackshift ? 'threshold-5sd+owen-30ms-backshift' : 'threshold-5sd'),
          filterPolicy: sampling ? sampling.filterApplied : 'unknown',
          sampling,
          generatedAt: new Date().toISOString(),
        };
      })(),
      protocol: {
        bodyMass: meta.weight,
        // sampleRate is the EFFECTIVE (measured) rate the detector actually used; the header's
        // declared value is kept separately as declaredSampleRate — never two unnamed rates
        // (GPT audit P2.4). MARS/external → effective null.
        sampleRate: ((typeof window !== 'undefined' && window.ForceCoreSampling) ? window.ForceCoreSampling.samplingProfile(time, meta.frequency).effectiveHz : null) ?? null,
        declaredSampleRate: meta.frequency,
        onsetBackshiftMs: useOnsetBackshift ? 30 : 0,
        takeoffLandingSource: 'raw_force_threshold',
      },
    };
  }

  // ── COMPARISON TABLE CONSTANTS ────────────────────────────────────────────

  // FORCE-WS-1b (2026-07-10): computed metrics carry a `formulaTip` (short formula
  // + 来源 when established) surfaced as a hover tip in surfaces we own (force-report
  // metric tables, 纵向 metric chip). Direct-read entries (raw peak/mean forces, phase
  // times, depth) get NO tip — nothing to explain beyond the measured value. Tips are
  // AUDITED against the computation above; where the code diverges from a textbook
  // model it is stated (e.g. 动态刚度 is a ratio, not a spring-mass model). Data-only:
  // no rendering/logic here (SUMMARY_METRICS is a pure def array).
  const ALL_SUMMARY_METRICS = [
    { key: 'jumpHeight',         label: '跳跃高度',     unit: 'cm',     section: '结果',  better: 'higher', formulaTip: '起跳速度² ÷ (2g)\n起跳速度由向心净冲量积分得出（冲量-动量法）' },
    { key: 'jumpHeightFT',       label: '跳高(飞行)',   unit: 'cm',     section: '结果',  better: 'higher', formulaTip: 'g × 腾空时间² ÷ 8\n飞行时间法' },
    { key: 'rsiMod',             label: 'RSI-mod',      unit: '',       section: '结果',  better: 'higher', formulaTip: '跳跃高度 ÷ 起跳时间\n来源：Ebben & Petushek 2010' },
    { key: 'ttt',                label: '起跳时间',     unit: 's',      section: '结果',  better: 'lower'  },
    { key: 'takeoffVelocity',    label: '起跳速度',     unit: 'm/s',    section: '结果',  better: 'higher', formulaTip: '起跳瞬时质心速度\n净冲量积分 ÷ 质量' },
    { key: 'cmDepth',            label: 'CM深度',       unit: 'cm',     section: '结果',  better: null     },
    { key: 'peakAcc',            label: '峰值加速度',   unit: 'm/s²',   section: '结果',  better: 'higher', formulaTip: '推进相 (F − 体重) ÷ 质量 的峰值' },
    { key: 'flightTime',         label: '飞行时间',     unit: 's',      section: '结果',  better: 'higher' },
    { key: 'unweightingTime',    label: '卸载时长',     unit: 's',      section: '阶段',  better: null     },
    { key: 'brakingTime',        label: '制动时长',     unit: 's',      section: '阶段',  better: null     },
    { key: 'propulsiveTime',     label: '推进时长',     unit: 's',      section: '阶段',  better: null     },
    { key: 'peakPropForce',      label: '峰值推进力',   unit: 'N',      section: '力量',  better: 'higher' },
    { key: 'relPeakPropForce',   label: '峰推 %BW',     unit: '%',      section: '力量',  better: 'higher', formulaTip: '峰值推进力 ÷ 体重 × 100%' },
    { key: 'avgPropForce',       label: '均推进力',     unit: 'N',      section: '力量',  better: 'higher' },
    { key: 'peakBrakingForce',   label: '峰值制动力',   unit: 'N',      section: '力量',  better: 'higher' },
    { key: 'relPeakBrakingForce',label: '峰制 %BW',     unit: '%',      section: '力量',  better: 'higher', formulaTip: '峰值制动力 ÷ 体重 × 100%' },
    { key: 'brakingRFD',         label: '制动 RFD',     unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '制动相力变化 ÷ 制动时长\n端点取 5 点均值降噪' },
    { key: 'rfd50',              label: 'RFD 0–50ms',   unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '(F@50ms − F@过零点) ÷ 0.05s\n自推进起点（过零点）计\n来源：Haff et al. 2015' },
    { key: 'rfd100',             label: 'RFD 0–100ms',  unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '(F@100ms − F@过零点) ÷ 0.1s\n自推进起点（过零点）计\n来源：Haff et al. 2015' },
    { key: 'rfd150',             label: 'RFD 0–150ms',  unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '(F@150ms − F@过零点) ÷ 0.15s\n自推进起点（过零点）计\n来源：Haff et al. 2015' },
    { key: 'rfd200',             label: 'RFD 0–200ms',  unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '(F@200ms − F@过零点) ÷ 0.2s\n自推进起点（过零点）计\n来源：Haff et al. 2015' },
    { key: 'rfd250',             label: 'RFD 0–250ms',  unit: 'N/s',    section: 'RFD',   better: 'higher', formulaTip: '(F@250ms − F@过零点) ÷ 0.25s\n自推进起点（过零点）计\n来源：Haff et al. 2015' },
    { key: 'peakPower',          label: '峰值推进功率', unit: 'W',      section: '功率',  better: 'higher', formulaTip: '瞬时功率 F × v 的推进相峰值' },
    { key: 'relPeakPower',       label: '相对峰功率',   unit: 'W/kg',   section: '功率',  better: 'higher', formulaTip: '峰值推进功率 ÷ 质量' },
    { key: 'avgPropPower',       label: '均推进功率',   unit: 'W',      section: '功率',  better: 'higher', formulaTip: '推进相瞬时功率 F × v 的平均值' },
    { key: 'relAvgPropPower',    label: '相对均功率',   unit: 'W/kg',   section: '功率',  better: 'higher', formulaTip: '均推进功率 ÷ 质量' },
    { key: 'peakBrakingPower',   label: '峰值制动功率', unit: 'W',      section: '功率',  better: null,     formulaTip: '瞬时功率 F × v 的制动相最负值' },
    { key: 'propNetImpulse',     label: '推进净冲量',   unit: 'N·s',    section: '冲量',  better: 'higher', formulaTip: '∫(F − 体重)dt，过零点→起跳（梯形积分）' },
    { key: 'relPropNetImpulse',  label: '相对推进冲量', unit: 'N·s/kg', section: '冲量',  better: 'higher', formulaTip: '推进净冲量 ÷ 质量' },
    { key: 'brakingNetImpulse',  label: '制动净冲量',   unit: 'N·s',    section: '冲量',  better: null,     formulaTip: '∫(F − 体重)dt，最低速→过零点（梯形积分）' },
    { key: 'impulseRatio',       label: '冲量比',       unit: '',       section: '冲量',  better: 'higher', formulaTip: '推进净冲量 ÷ 制动净冲量' },
    { key: 'stiffness',          label: '动态刚度',     unit: 'N/m',    section: '力学',  better: 'higher', formulaTip: '峰值推进力 ÷ 质心下蹲深度\n注：比值式动态刚度，非弹簧-质量模型' },
    { key: 'springCorr',         label: '弹簧相关系数', unit: '',       section: '力学',  better: 'higher', formulaTip: 'Pearson(GRF, 质心位移)，下蹲窗口 [起跳前→起跳]' },
  ];

  const DEFAULT_METRIC_KEYS = [
    'jumpHeight', 'rsiMod', 'ttt', 'takeoffVelocity',
    'peakPropForce', 'relPeakPropForce', 'brakingRFD',
    'peakPower', 'propNetImpulse', 'impulseRatio',
  ];

  const SECTION_COLORS = {
    '结果': '#60a5fa', '阶段': '#a78bfa', '力量': '#fb923c',
    'RFD': '#fbbf24',  '功率': '#f87171', '冲量': '#34d399', '力学': '#94a3b8',
  };

  // ── 10. CMJ SESSION DETAIL ─────────────────────────────────────────────────
  // Read-only analysis view for a previously saved session.
  // Reads from the stored summary (200pt curves + metrics + keyPts) — no need
  // to re-upload the raw VALD file. Mirrors the live Analysis layout where data
  // permits; falls back gracefully for fields that aren't stored.
  // Reconstruct the inputs that the live CMJChart + LoopChart expect, from
  // the stored 200pt downsampled curve + keyPts + metrics. This lets us reuse
  // the exact same chart components in the read-only session detail view, so
  // visual style is identical to the live Analysis page.
  //
  // What's recoverable from a stored session:
  //   time      — derived from curve.t × ttt (ttt stored as a metric)
  //   total (N) — derived from curve.f × bw_n; bw_n back-derived from the
  //               (peakPropForce in N) / (max curve.f in propulsive phase)
  //   vel, disp — stored verbatim in curve.v / curve.d
  //   phases    — mapped from keyPts time fractions to curve indices
  // What's lost:
  //   left / right split (only total is stored) — passed as 50/50 of total
  //   pre-onset baseline and post-takeoff landing (curve only covers onset→takeoff)
  function reconstructTrialForLive(trial) {
    const c = trial.curve;
    if (!c?.t?.length) return null;
    const n = c.t.length;
    const ttt = trial.metrics?.ttt > 0 ? trial.metrics.ttt : 1;

    const time = c.t.map(t => t * ttt);
    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;
    };

    // Phase indices from key-points
    const kp = trial.keyPts || {};
    const phases = {
      onset:     0,
      minForce:  kp.b != null ? nearestIdx(kp.b) : Math.floor(n * 0.10),
      minVel:    kp.c != null ? nearestIdx(kp.c) : Math.floor(n * 0.30),
      zeroCross: kp.e != null ? nearestIdx(kp.e) : Math.floor(n * 0.55),
      takeoff:   n - 1,
      landing:   null,
    };

    // Back-derive body weight from peak propulsive force
    const propStart = phases.zeroCross, propEnd = phases.takeoff;
    let peakFRatio = 0;
    for (let i = propStart; i <= propEnd; i++) {
      if (c.f[i] > peakFRatio) peakFRatio = c.f[i];
    }
    const peakFNewtons = trial.metrics?.peakPropForce;
    const bw_n = (peakFNewtons && peakFRatio > 0) ? peakFNewtons / peakFRatio : 700;
    const mass_kg = bw_n / G;

    const total = c.f.map(v => v * bw_n);
    const left  = total.map(v => v / 2);  // L/R split not stored; placeholder
    const right = total.map(v => v / 2);

    const vel  = c.v.slice();
    const disp = c.d.slice();
    const quietRef = 0;  // vel/disp arrays align with the curve start (= onset)

    const velAt  = i => (i >= 0 && i < vel.length)  ? vel[i]  : 0;
    const dispAt = i => (i >= 0 && i < disp.length) ? disp[i] : 0;

    const jump = { index: trial.index, phases, metrics: trial.metrics, vel, disp, quietRef };

    return { time, total, left, right, vel, disp, velAt, dispAt, phases, jump, bw_n, mass_kg };
  }

  function CMJSessionDetail({ session, athlete, onBack }) {
    const [selectedIdx, setSelectedIdx] = useState(0);

    if (!session || !session.trials?.length) {
      return (
        <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'var(--muted)', gap: 12 }}>
          <span>该会话无数据</span>
          {onBack && <button className="btn" onClick={onBack}>← 返回</button>}
        </div>
      );
    }

    const trial = session.trials[selectedIdx];
    const TYPE_COLORS_LOCAL = { 'Ⅰ': '#34d399', 'Ⅱ': '#fbbf24', 'Ⅲ': '#60a5fa', 'Ⅳ': '#f87171' };
    const live = reconstructTrialForLive(trial);
    // Make a jumps[] + compareSelected so LoopChart can render just this trial
    const jumpsForLoop = live ? [live.jump] : [];
    const compareSelectedSet = new Set(jumpsForLoop.length ? [0] : []);

    return (
      <div style={{ flex: 1, minWidth: 0, padding: '20px 24px 48px', display: 'flex', flexDirection: 'column', gap: 16, overflowY: 'auto' }}>
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          {onBack && (
            <button className="btn ghost" onClick={onBack} style={{ fontSize: 12, padding: '5px 10px' }}>
              ← 返回
            </button>
          )}
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              CMJ 会话详情 · 只读视图
            </div>
            <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginTop: 2 }}>
              {athlete?.name || '本人'} · {session.date}
              <span style={{ fontSize: 12, color: 'var(--muted)', marginLeft: 8, fontWeight: 400 }}>
                · {session.jumpCount} trials
                {session.fileName && ` · ${session.fileName}`}
              </span>
            </div>
          </div>
        </div>

        {/* Trial selector */}
        {session.trials.length > 1 && (
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            {session.trials.map((tr, i) => {
              const active = i === selectedIdx;
              const tc = TYPE_COLORS_LOCAL[tr.type] || 'var(--muted)';
              return (
                <button key={i} onClick={() => setSelectedIdx(i)} style={{
                  display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
                  padding: '8px 14px', borderRadius: 8, cursor: 'pointer',
                  background: active ? 'var(--accent-soft)' : 'var(--panel-2)',
                  border: '1px solid ' + (active ? 'rgba(59,130,246,.4)' : 'var(--border)'),
                  color: 'inherit', font: 'inherit',
                }}>
                  <span style={{ fontSize: 11, color: active ? 'var(--accent-2)' : 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.05em' }}>
                    Jump {tr.index}
                  </span>
                  <span style={{ fontSize: 14, fontWeight: 700, color: active ? 'var(--pos)' : 'var(--text-2)', fontFamily: 'var(--font-mono)' }}>
                    {tr.metrics?.jumpHeight != null ? tr.metrics.jumpHeight.toFixed(1) + ' cm' : '—'}
                  </span>
                  <span style={{ fontSize: 10, color: tc, fontWeight: 700 }}>{tr.type}</span>
                </button>
              );
            })}
          </div>
        )}

        {/* Charts — reuses the live Analysis components (CMJChart + LoopChart)
            with reconstructed inputs so visual style matches the live page.
            Limitation: pre-onset baseline + post-takeoff landing not stored,
            so F-t only spans onset→takeoff; L/R split shows as 50/50 placeholder. */}
        {live ? (
          <>
            <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '6px 8px' }}>
              <CMJChart
                time={live.time}
                left={live.left} right={live.right} total={live.total}
                velAt={live.velAt} dispAt={live.dispAt}
                phases={live.phases}
                bw_n={live.bw_n} mass_kg={live.mass_kg}
                overlays={{ vel: true, disp: false, acc: false, power: false }}
                normalizeX={false}
                classResult={{ type: trial.type, isBimodal: trial.isBimodal, isLF1: trial.isLF1 }}
              />
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 4px 4px' }}>
                <LoopChart mode="fd" jumps={jumpsForLoop} compareSelected={compareSelectedSet}
                  total={live.total} bw_n={live.bw_n}/>
              </div>
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '8px 4px 4px' }}>
                <LoopChart mode="fv" jumps={jumpsForLoop} compareSelected={compareSelectedSet}
                  total={live.total} bw_n={live.bw_n}/>
              </div>
            </div>
            <div style={{ fontSize: 10, color: 'var(--muted-2)', fontStyle: 'italic', padding: '0 4px' }}>
              注：仅显示 onset → takeoff 区间（保存时未保留基线和着陆）；左右分量未单独存储，以总力 50/50 占位。
            </div>
          </>
        ) : (
          <div style={{ padding: 24, background: 'var(--panel)', border: '1px dashed var(--border)', borderRadius: 10, textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>
            该会话无可重建的曲线数据
          </div>
        )}

        {/* Metric tables — uses the SAME ComparisonTable component as the live
            Analysis page so the two views are visually identical. We just need
            to shim session.trials → jumps shape and build classifications. */}
        {(() => {
          const jumpsShim = session.trials.map(tr => ({
            index: tr.index,
            metrics: tr.metrics || {},
          }));
          const clsShim = session.trials.map(tr => ({
            type: tr.type, isLF1: tr.isLF1, isBimodal: tr.isBimodal,
          }));
          return (
            <ComparisonTable
              jumps={jumpsShim}
              classifications={clsShim}
              selectedIdx={selectedIdx}
              onSelectJump={setSelectedIdx}
              selectedMetricKeys={DEFAULT_METRIC_KEYS}
              onToggleMetric={() => {}}    // read-only mode — picker disabled
              onResetMetrics={() => {}}
            />
          );
        })()}
      </div>
    );
  }

  // (SessionFTChart / SessionMetricTable / SessionTrialsTable all removed —
  // CMJSessionDetail now reuses the live Analysis components CMJChart +
  // LoopChart + ComparisonTable, so both views are visually identical.)

  // ── 11. CMJ LONGITUDINAL VIEW (single athlete, multi session) ──────────────
  // Metric trends across sessions + asymmetry evolution + type evolution + F-t overlay.
  function CMJLongitudinalView({ athlete, sessions, onBack, onViewSession }) {
    // Comparability rule (GPT audit P1.2): a longitudinal trend IS a cross-session comparison, so
    // rate-'limited' / invalid sessions are excluded from it (reads the persisted sampling status
    // inline — the detection layer must not reach into the session-source module).
    const _cmp = (s) => { const st = s && s.algorithmRef && s.algorithmRef.sampling ? s.algorithmRef.sampling.status : null; return st !== 'limited' && st !== 'invalid' && st !== 'non-uniform'; };
    const forceSource = window.ForceSessionSource;
    const _allSorted = sessions.map(s =>
      forceSource && typeof forceSource.resolveEffectiveSession === 'function'
        ? forceSource.resolveEffectiveSession(s) : s
    ).sort((a, b) => new Date(a.date) - new Date(b.date));
    const excludedCount = _allSorted.filter(s => !_cmp(s)).length;
    const sorted = _allSorted.filter(_cmp);
    // P2-A: 默认展示 mRSI（最灵敏监控指标），与 Output 面板首位一致（Gathercole 2015）
    const [selectedKey, setSelectedKey] = useState('rsiMod');
    const watched = ['rsiMod', 'relPeakPower', 'jumpHeight', 'ttt', 'peakPropForce', 'cmDepth', 'brakingRFD', 'asymBraking', 'asymProp'];
    const availableKeys = watched.filter(k => sorted.some(s => s.best?.[k] != null));
    const def = ALL_SUMMARY_METRICS.find(m => m.key === selectedKey) || ALL_SUMMARY_METRICS.find(m => m.key === 'jumpHeight');

    // Trend chart for selected metric (best per session)
    const trendData = sorted
      .map(s => ({ x: s.date, y: s.best?.[selectedKey] }))
      .filter(d => d.y != null && isFinite(d.y));

    const W1 = 740, H1 = 220, ML1 = 56, MR1 = 16, MT1 = 14, MB1 = 36;
    const PW1 = W1 - ML1 - MR1, PH1 = H1 - MT1 - MB1;
    let yLo = Infinity, yHi = -Infinity;
    trendData.forEach(d => { if (d.y < yLo) yLo = d.y; if (d.y > yHi) yHi = d.y; });
    if (!isFinite(yLo)) { yLo = 0; yHi = 1; }
    const ypad = (yHi - yLo) * 0.15 || Math.abs(yHi) * 0.1 || 0.5;
    yLo -= ypad; yHi += ypad;
    const xS1 = i => trendData.length > 1 ? ML1 + (i / (trendData.length - 1)) * PW1 : ML1 + PW1 / 2;
    const yS1 = v => MT1 + PH1 * (1 - (v - yLo) / (yHi - yLo));
    const trendPath = trendData.map((d, i) => (i === 0 ? 'M' : 'L') + xS1(i).toFixed(1) + ',' + yS1(d.y).toFixed(1)).join('');
    // P3-C: MDC band — ±MDC/2 around latest data point value
    const CMJ_ICC_MAP = { rsiMod: 'cmj_mrsi', jumpHeight: 'cmj_jh', relPeakPower: 'cmj_peakPower' };
    const mdcBand1 = (() => {
      if (trendData.length < 2) return null;
      const D = window.DASHBOARD_DATA;
      const iccKey = CMJ_ICC_MAP[selectedKey];
      if (!iccKey) return null;
      const icc = (D.ICC_USER?.[iccKey] ?? D.ICC_DEFAULTS?.[iccKey]);
      if (icc == null) return null;
      const vals = trendData.map(d => d.y);
      const m = vals.reduce((a, b) => a + b, 0) / vals.length;
      const sd = Math.sqrt(vals.reduce((s, v) => s + (v - m) ** 2, 0) / vals.length);
      const mdc = D.computeMDC(sd, icc);
      if (!mdc) return null;
      const latestY = trendData[trendData.length - 1].y;
      return { yTop: yS1(latestY + mdc / 2), yBot: yS1(latestY - mdc / 2), mdc };
    })();

    // Asymmetry evolution
    const asymData = sorted.map(s => ({
      date: s.date,
      braking: s.best?.asymBraking || 0,
      prop: s.best?.asymProp || 0,
    }));

    // Type evolution: stacked counts per session
    const typeColors = { 'Ⅰ': '#34d399', 'Ⅱ': '#fbbf24', 'Ⅲ': '#60a5fa', 'Ⅳ': '#f87171' };

    // F-t curve overlay across sessions (one trace per session)
    const W2 = 740, H2 = 260, ML2 = 52, MR2 = 18, MT2 = 18, MB2 = 36;
    const PW2 = W2 - ML2 - MR2, PH2 = H2 - MT2 - MB2;
    const representativeCurve = s => {
      const trial = forceSource && typeof forceSource.resolveRepresentativeTrial === 'function'
        ? forceSource.resolveRepresentativeTrial(s)
        : s.trials?.find(t => t && String(t.index) === String(s.representative?.index));
      return (trial || s.trials?.[0])?.curve;
    };
    const allCurves = sorted.map(representativeCurve).filter(c => c?.t?.length);
    const allF2 = allCurves.flatMap(c => c.f).filter(isFinite);
    let fLo = allF2.length ? Math.min(...allF2) : 0;
    let fHi = allF2.length ? Math.max(...allF2) : 1;
    const fpad = (fHi - fLo) * 0.1 || 0.1;
    fLo -= fpad; fHi += fpad;
    const xS2 = t => ML2 + t * PW2;
    const yS2 = f => MT2 + PH2 * (1 - (f - fLo) / (fHi - fLo));

    // session palette (chronological)
    const palette = ['#3b82f6', '#60a5fa', '#22d3ee', '#34d399', '#84cc16', '#fbbf24', '#fb923c', '#f87171'];

    return (
      <div style={{ flex: 1, minWidth: 0, padding: '20px 24px 48px', display: 'flex', flexDirection: 'column', gap: 16, overflowY: 'auto' }}>
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          {onBack && <button className="btn ghost" onClick={onBack} style={{ fontSize: 12 }}>← 返回</button>}
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>CMJ 纵向分析 · 同一运动员多次会话</div>
            {excludedCount > 0 && <div style={{ fontSize: 11, color: 'var(--danger, #b45309)', marginTop: 4 }}>⚠ {excludedCount} 个受限会话（采样率不足/时间轴异常）已排除出趋势比较</div>}
            <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginTop: 2 }}>{athlete?.name} · {sorted.length} 次会话 · {sorted[0]?.date} → {sorted[sorted.length - 1]?.date}</div>
          </div>
        </div>

        {/* Session list — 明细列表（从个人页力板卡迁入模块）：点击进单次详情 */}
        {onViewSession && sorted.length > 0 && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '2px 0' }}>
            <span style={{ fontSize: 10, color: 'var(--muted-2)', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 600 }}>会话明细</span>
            {[...sorted].reverse().map(s => (
              <button key={s.id} onClick={() => onViewSession(s.id)} className="btn ghost" style={{ fontSize: 11, padding: '3px 9px', fontFamily: 'var(--font-mono)', color: 'var(--text-2)', border: '1px solid var(--border)' }}>{s.date} ↗</button>
            ))}
          </div>
        )}

        {/* Metric trend */}
        <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>指标趋势（每次会话最佳值）</span>
            <select value={selectedKey} onChange={e => setSelectedKey(e.target.value)} style={{
              background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 5,
              padding: '3px 8px', color: 'var(--text)', fontSize: 11.5, fontFamily: 'var(--font-sans)', cursor: 'pointer',
            }}>
              {availableKeys.map(k => {
                const d = ALL_SUMMARY_METRICS.find(m => m.key === k);
                return <option key={k} value={k}>{d?.label || k}</option>;
              })}
            </select>
          </div>
          <svg viewBox={'0 0 ' + W1 + ' ' + H1} style={{ width: '100%', display: 'block' }}>
            <line x1={ML1} y1={MT1 + PH1} x2={ML1 + PW1} y2={MT1 + PH1} stroke="rgba(15,23,42,.12)"/>
            <line x1={ML1} y1={MT1} x2={ML1} y2={MT1 + PH1} stroke="rgba(15,23,42,.12)"/>
            {[0, 0.25, 0.5, 0.75, 1].map(p => {
              const v = yLo + (yHi - yLo) * (1 - p);
              return <g key={p}>
                <line x1={ML1} y1={MT1 + PH1 * p} x2={ML1 + PW1} y2={MT1 + PH1 * p} stroke="rgba(15,23,42,.05)"/>
                <text x={ML1 - 6} y={MT1 + PH1 * p + 3} textAnchor="end" fontSize="11" fill="var(--muted)">{Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(2)}</text>
              </g>;
            })}
            {trendData.map((d, i) => (
              <text key={i} x={xS1(i)} y={MT1 + PH1 + 14} textAnchor="middle" fontSize="11" fill="var(--muted)">{d.x.slice(5)}</text>
            ))}
            {mdcBand1 && (
              <g>
                <rect x={ML1} y={mdcBand1.yTop} width={PW1} height={mdcBand1.yBot - mdcBand1.yTop} fill="rgba(148,163,184,.13)" stroke="rgba(148,163,184,.25)" strokeWidth="0.5" strokeDasharray="3 2"/>
                <text x={ML1 + PW1 - 2} y={mdcBand1.yTop - 3} textAnchor="end" fontSize="10" fill="var(--muted-2)">MDC±{mdcBand1.mdc.toFixed(2)}</text>
              </g>
            )}
            <path d={trendPath} fill="none" stroke="#60a5fa" strokeWidth="2"/>
            {trendData.map((d, i) => (
              <circle key={i} cx={xS1(i)} cy={yS1(d.y)} r="3.5" fill="var(--bg)" stroke="#60a5fa" strokeWidth="1.8"/>
            ))}
            <text x="14" y={MT1 + PH1 / 2} textAnchor="middle" fontSize="11" fill="var(--muted)" transform={'rotate(-90,14,' + (MT1 + PH1 / 2) + ')'}>{def?.label}{def?.unit ? ` (${def.unit})` : ''}</text>
          </svg>
        </div>

        {/* P2A-3: TTT 独立子图 — 疲劳代偿检测：TTT↑ 而 JH 不变 = 代偿（Gathercole 2015） */}
        {(() => {
          const tttData = sorted.map(s => ({ x: s.date, y: s.best?.ttt })).filter(d => d.y != null && isFinite(d.y));
          if (tttData.length < 2) return null;
          const WT = 740, HT = 130, MLT = 56, MRT = 16, MTT = 14, MBT = 28;
          const PWT = WT - MLT - MRT, PHT = HT - MTT - MBT;
          let yLT = Infinity, yHT = -Infinity;
          tttData.forEach(d => { if (d.y < yLT) yLT = d.y; if (d.y > yHT) yHT = d.y; });
          const ypadT = (yHT - yLT) * 0.2 || 0.05;
          yLT -= ypadT; yHT += ypadT;
          const xST = i => tttData.length > 1 ? MLT + (i / (tttData.length - 1)) * PWT : MLT + PWT / 2;
          const yST = v => MTT + PHT * (1 - (v - yLT) / (yHT - yLT));
          const pathT = tttData.map((d, i) => (i === 0 ? 'M' : 'L') + xST(i).toFixed(1) + ',' + yST(d.y).toFixed(1)).join('');
          return (
            <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
                <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>起跳时间趋势 TTT（疲劳代偿指标）</span>
                <span style={{ fontSize: 10, color: 'var(--muted-2)', fontStyle: 'italic' }}>TTT↑ 而 JH 不变 = 代偿性疲劳 · Gathercole 2015</span>
              </div>
              <svg viewBox={'0 0 ' + WT + ' ' + HT} style={{ width: '100%', display: 'block' }}>
                <line x1={MLT} y1={MTT + PHT} x2={MLT + PWT} y2={MTT + PHT} stroke="rgba(15,23,42,.12)"/>
                <line x1={MLT} y1={MTT} x2={MLT} y2={MTT + PHT} stroke="rgba(15,23,42,.12)"/>
                {[0, 0.5, 1].map(p => {
                  const v = yLT + (yHT - yLT) * (1 - p);
                  return <g key={p}>
                    <line x1={MLT} y1={MTT + PHT * p} x2={MLT + PWT} y2={MTT + PHT * p} stroke="rgba(15,23,42,.05)"/>
                    <text x={MLT - 6} y={MTT + PHT * p + 3} textAnchor="end" fontSize="10" fill="var(--muted)">{v.toFixed(2)}</text>
                  </g>;
                })}
                {tttData.map((d, i) => (
                  <text key={i} x={xST(i)} y={MTT + PHT + 14} textAnchor="middle" fontSize="10" fill="var(--muted)">{d.x.slice(5)}</text>
                ))}
                <path d={pathT} fill="none" stroke="rgba(251,146,60,.9)" strokeWidth="2"/>
                {tttData.map((d, i) => (
                  <circle key={i} cx={xST(i)} cy={yST(d.y)} r="3" fill="var(--bg)" stroke="rgba(251,146,60,.9)" strokeWidth="1.8"/>
                ))}
                <text x="14" y={MTT + PHT / 2} textAnchor="middle" fontSize="10" fill="var(--muted)" transform={'rotate(-90,14,' + (MTT + PHT / 2) + ')'}>TTT (s)</text>
              </svg>
            </div>
          );
        })()}

        {/* Asymmetry evolution */}
        <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
          <div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600, marginBottom: 8 }}>左右不对称演变 (%)</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {asymData.map((d, i) => {
              const maxAbs = Math.max(Math.abs(d.braking), Math.abs(d.prop));
              const sevCol = maxAbs > 15 ? 'var(--neg)' : maxAbs > 10 ? 'var(--warn)' : 'var(--pos)';
              return (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: '90px 1fr 1fr', gap: 10, alignItems: 'center', fontSize: 11 }}>
                  <span style={{ color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{d.date}</span>
                  <AsymRowBar label="制动" value={d.braking}/>
                  <AsymRowBar label="推进" value={d.prop}/>
                </div>
              );
            })}
          </div>
          <div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted-2)', fontStyle: 'italic' }}>
            正值 = 左侧主导，负值 = 右侧主导 · &gt;15% 红色预警，&gt;10% 黄色观察
          </div>
        </div>

        {/* Type evolution */}
        <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
          <div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600, marginBottom: 8 }}>力-时分型演变（每次会话各试次类型分布）</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {sorted.map((s, i) => {
              const counts = { 'Ⅰ': 0, 'Ⅱ': 0, 'Ⅲ': 0, 'Ⅳ': 0 };
              s.trials.forEach(t => { if (counts[t.type] != null) counts[t.type]++; });
              const total = Object.values(counts).reduce((a, b) => a + b, 0);
              return (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: '90px 1fr 120px', gap: 10, alignItems: 'center', fontSize: 11 }}>
                  <span style={{ color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{s.date}</span>
                  <div style={{ display: 'flex', height: 14, borderRadius: 3, overflow: 'hidden', background: 'var(--panel-2)' }}>
                    {Object.entries(counts).map(([t, c]) => c > 0 && (
                      <div key={t} title={`${t}: ${c} trials`} style={{ width: (c / total * 100) + '%', background: typeColors[t] }}/>
                    ))}
                  </div>
                  <div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
                    {Object.entries(counts).filter(([_, c]) => c > 0).map(([t, c]) => `${t}×${c}`).join(' · ')}
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        {/* F-t curve overlay across sessions */}
        {allCurves.length > 0 && (
          <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
            <div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600, marginBottom: 8 }}>F-t 曲线叠加（每会话取 Best Trial）</div>
            <svg viewBox={'0 0 ' + W2 + ' ' + H2} style={{ width: '100%', display: 'block' }}>
              <line x1={ML2} y1={MT2 + PH2} x2={ML2 + PW2} y2={MT2 + PH2} stroke="rgba(15,23,42,.12)"/>
              <line x1={ML2} y1={MT2} x2={ML2} y2={MT2 + PH2} stroke="rgba(15,23,42,.12)"/>
              {fLo < 1 && fHi > 1 && (
                <line x1={ML2} y1={yS2(1)} x2={ML2 + PW2} y2={yS2(1)} stroke="rgba(15,23,42,.2)" strokeWidth="1" strokeDasharray="4 3"/>
              )}
              {sorted.map((s, idx) => {
                const c = representativeCurve(s);
                if (!c?.t?.length) return null;
                const color = palette[idx % palette.length];
                const d = c.t.map((t, i) => (i === 0 ? 'M' : 'L') + xS2(t).toFixed(1) + ',' + yS2(c.f[i]).toFixed(1)).join('');
                return <path key={s.id} d={d} fill="none" stroke={color} strokeWidth="1.6" strokeOpacity=".75"/>;
              })}
              {[0, 0.25, 0.5, 0.75, 1.0].map(t => (
                <text key={t} x={xS2(t)} y={MT2 + PH2 + 14} textAnchor="middle" fontSize="10.5" fill="var(--muted)">{(t * 100).toFixed(0)}%</text>
              ))}
              <text x="14" y={MT2 + PH2 / 2} textAnchor="middle" fontSize="11" fill="var(--muted)" transform={'rotate(-90,14,' + (MT2 + PH2 / 2) + ')'}>GRF / BW</text>
              <text x={ML2 + PW2 / 2} y={H2 - 4} textAnchor="middle" fontSize="11" fill="var(--muted)">Normalised time</text>
            </svg>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
              {sorted.map((s, idx) => (
                <span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 10.5, color: 'var(--text-2)' }}>
                  <span style={{ width: 14, height: 2, background: palette[idx % palette.length], borderRadius: 1 }}/>
                  {s.date}
                </span>
              ))}
            </div>
          </div>
        )}
      </div>
    );
  }

  function AsymRowBar({ label, value }) {
    const abs = Math.abs(value || 0);
    const sevCol = abs > 15 ? 'var(--neg)' : abs > 10 ? 'var(--warn)' : 'var(--pos)';
    const leftDom = (value || 0) > 0;
    const barW = Math.min(abs * 1.5, 48);
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
        <span style={{ color: 'var(--muted-2)', fontSize: 9, width: 26 }}>{label}</span>
        <div style={{ flex: 1, height: 5, background: 'var(--panel-2)', borderRadius: 3, position: 'relative' }}>
          <div style={{
            position: 'absolute', top: 0, height: '100%',
            left: leftDom ? `calc(50% - ${barW}px)` : '50%',
            width: barW + 'px', background: sevCol, borderRadius: 3,
          }}/>
          <div style={{ position: 'absolute', left: '50%', top: -2, width: 1, height: 9, background: 'var(--border-strong)' }}/>
        </div>
        <span style={{ color: sevCol, fontFamily: 'var(--font-mono)', fontSize: 10, width: 42, textAlign: 'right' }}>
          {leftDom ? 'L' : 'R'} {abs.toFixed(1)}%
        </span>
      </div>
    );
  }

  window.CMJPanel = CMJPanel;
  // FORCE-WS-5a (2026-07-12): ForceCompareView (+ the CMJCompareView compat alias)
  // extracted to force-compare.jsx — its window exposure now lives there.
  window.CMJSessionDetail = CMJSessionDetail;
  window.CMJLongitudinalView = CMJLongitudinalView;
  window.__FORCE_TEST_INTERNALS__ = window.__FORCE_TEST_INTERNALS__ || {};
  window.__FORCE_TEST_INTERNALS__.cmj = {
    detectAllJumps,
    computeMetrics,
    filtfilt,
    findBestQuietWindow,
    // Exposed so reports can reuse the exact live analysis charts (rebuilt from
    // a stored session's downsampled curves — same path as CMJSessionDetail).
    CMJChart,
    CMJNormChart,
    LoopChart,
    reconstructTrialForLive,
    NORM_COLORS,
    ALL_SUMMARY_METRICS,
    SECTION_COLORS,
    DEFAULT_METRIC_KEYS,
    // FORCE-WS-5a (2026-07-12): shared with force-compare.jsx — ForceCompareView's
    // CompareLoopChart reads these key-point descriptions. Still used in-file by
    // CMJNormChart / LoopChart; exposed minimally so the extracted view reuses it.
    KPOINT_DESCS,
  };
})();
