// sj.jsx  v8  —  SJ 力板分析（MARS 验证：峰力 0.0% · 净冲量 0.4% · JH 0.9% · FT 0.7%）
// 职责：算法（自适应 quietSkip · 原始信号腾空检测 · 300ms BW 窗口 · 反向 onset 搜索）· 分析面板
// ⚠️ 已知限制：无 session 持久化（Phase 2B 补全）· 无纵向视图 · CM 污染检测已实现但无纵向追踪
// Supported formats: VALD ForceDecks (.xlsx/.csv/.tsv), General CM format.
// Propulsive phase only — detects onset (zeroCross) → takeoff → landing.
// CM contamination flagged when pre-onset velocity dip exceeds 0.1 m/s.

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

  const G = 9.81;
  const TAKEOFF_N = 20;
  let forceCoreParser = null;
  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);
    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('文件表头中未找到体重（Weight）。请确认这是 VALD ForceDecks 原始导出文件。');
    const time = [], left = [], right = [];
    for (let i = headerRow + 1; i < rows.length; i++) {
      const t = parseFloat(rows[i][0]), l = parseFloat(rows[i][1]), 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 < 200) throw new Error(`数据行数过少（${time.length} 行）。请确认上传的是原始力-时间数据文件，而非汇总表。`);
    return { meta, time, left, right };
  }

  function detectRowsFormat(rows) {
    if (forceCoreParser) return forceCoreParser.detectRowsFormat(rows);
    if (!rows || rows.length < 2) return 'vald';
    const r0 = rows[0].map(c => String(c ?? '').trim());
    if (r0[0] === 'Name' && r0.some(c => c === 'Time_increment')) return 'general';
    return 'vald';
  }

  function parseGeneralRows(rows) {
    if (forceCoreParser) return forceCoreParser.parseGeneralRows(rows);
    let timeIncrement = 0.001, startDate = '', dataHeaderRow = -1;
    let foundSeparator = false;
    for (let i = 1; i < Math.min(rows.length, 40); i++) {
      const first = String(rows[i][0] ?? '').trim();
      if (!first) { foundSeparator = true; continue; }
      if (foundSeparator) { dataHeaderRow = i; break; }
      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('未能识别数据表头行。请确认文件包含 Name/Unit/Time_increment 元数据块。');
    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;
    });
    if (leftCol < 0 && rightCol < 0) {
      if (headers.length >= 3) { leftCol = 0; rightCol = 1; totalCol = 2; }
      else if (headers.length >= 1) { totalCol = 0; }
    }
    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;
      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 < 200) throw new Error(`只找到 ${time.length} 行数据，不足以分析。`);
    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;
    let bestMean = total.slice(0, winN).reduce((a, b) => a + b, 0) / winN, bestSD = Infinity;
    for (let i = 0; i < searchLen; i++) {
      let sum = 0;
      for (let j = i; j < i + winN; j++) sum += total[j];
      const mean = sum / winN;
      if (mean < BW_MIN_N || mean > BW_MAX_N) continue;
      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; }
    }
    if (bestSD > 25) throw new Error(`未找到稳定的静止站立期（最佳 300ms 窗口 SD = ${bestSD.toFixed(1)}N）。`);
    const weight_kg = bestMean / G;
    return { meta: { weight: +weight_kg.toFixed(2), weightEstimated: true, 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);
    return new Promise((resolve, reject) => {
      const isXLSX = /\.xlsx?$/i.test(file.name);
      const dispatchRows = (rows) => {
        const fmt = detectRowsFormat(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.');
            const wb = XLSX.read(new Uint8Array(ev.target.result), { type: 'array' });
            const ws = wb.Sheets[wb.SheetNames[0]];
            resolve(dispatchRows(XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })));
          } catch (err) { reject(err); }
        };
        reader.onerror = () => reject(new Error('文件读取失败。'));
        reader.readAsArrayBuffer(file);
      } else {
        const reader = new FileReader();
        reader.onload = ev => {
          try {
            const lines = ev.target.result.split(/\r?\n/);
            const delim = detectDelimiter(lines.slice(0, 10).join('\n'));
            resolve(dispatchRows(lines.map(l => l.split(delim))));
          } catch (err) { reject(err); }
        };
        reader.onerror = () => reject(new Error('文件读取失败。'));
        reader.readAsText(file);
      }
    });
  }

  // ── 2. SIGNAL PROCESSING ──────────────────────────────────────────────────

  function butterworthCoeffs(fc, fs) {
    if (forceCoreFilter?.butterworthCoeffs) return forceCoreFilter.butterworthCoeffs(fc, fs);
    const w0 = 2 * Math.PI * fc / fs, cosw = Math.cos(w0), sinw = Math.sin(w0);
    const alpha = sinw / (2 / Math.SQRT2), 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 };
  }
  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];
    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;
  }
  function filtfilt(x, fc, fs) {
    if (forceCoreFilter?.filtfilt) return forceCoreFilter.filtfilt(x, fc, fs);
    const c = butterworthCoeffs(fc, fs), 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);
    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;
      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 };
  }

  // ── 3. SJ DETECTION ──────────────────────────────────────────────────────
  // Propulsive onset = last velocity zero-crossing before takeoff (zeroCross),
  // backshifted 30ms. CM contamination flagged if min pre-onset velocity < −0.1 m/s.

  function detectAllSJJumps(time, totalRaw, leftRaw, rightRaw, mass_kg, useOnsetBackshift = true) {
    // ONE sampling profile drives fs + window/integration dt. Forward gaps use the median Δt
    // with a visible non-uniform warning; only non-finite / non-increasing axes are 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 n = totalRaw.length;
    const bw_n = mass_kg * G;
    const fs = _sp ? _sp.effectiveHz : Math.round(1 / dt);

    let total = totalRaw, left = leftRaw, right = rightRaw;
    if (fs >= 100) {
      total = filtfilt(totalRaw, 24, fs);
      left  = filtfilt(leftRaw,  24, fs);
      right = filtfilt(rightRaw, 24, fs);
    }

    // Find first stable on-plate period (≥200ms within ±15% BW)
    const stableMin = Math.round(0.2 / dt);
    let firstOnPlate = -1;
    { let consec = 0, cand = -1;
      for (let i = 0; i < n; 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('未检测到运动员站在力板上。请检查力值单位是否为 N，以及文件头部体重是否正确。');

    // Adaptive quiet skip: at most 1 s, but capped at 40% of usable recording.
    // Prevents MIN_ON from landing at/past takeoff on short (~2 s) MARS exports.
    const quietSkip = Math.min(
      Math.round(1.0 / dt),
      Math.max(Math.round(0.3 / dt), Math.round(0.40 * (n - firstOnPlate)))
    );
    const MIN_ON = firstOnPlate + quietSkip;
    const FLIGHT_MIN = Math.round(0.05 / dt);

    // Detect flight phases on the RAW signal so the 20N threshold is not
    // shifted by the low-pass filter (filtfilt smears the takeoff/landing
    // edges, causing both to be detected ~20 ms early; using raw eliminates
    // this filter-induced flight-time shortfall of ~40–50 ms).
    const flights = [];
    let fi = MIN_ON;
    while (fi < n) {
      if (totalRaw[fi] < TAKEOFF_N) {
        const flightStart = fi;
        while (fi < n && totalRaw[fi] < TAKEOFF_N) fi++;
        if (fi - flightStart >= FLIGHT_MIN)
          flights.push({ takeoff: flightStart, landing: fi < n ? fi : null });
      } else fi++;
    }
    if (flights.length === 0) throw new Error('未检测到有效的 SJ 试次。请确认录制包含完整的腾空期（力 < 20N）。');

    const jumps = [];

    for (const { takeoff, landing } of flights) {
      // Local BW reference — find the quietest 300ms window in the full
      // standing phase [firstOnPlate+50ms, takeoff-100ms].  Using a fixed
      // short window (not quietSkip) guarantees the search range always fits
      // inside even 2-second recordings, eliminating the fallback that caused
      // BW to be under-estimated from a non-representative settling period.
      const BW_WIN = Math.round(0.300 / dt);
      const bwFrom = firstOnPlate + Math.round(0.050 / dt);
      const bwTo   = Math.max(bwFrom, takeoff - Math.round(0.100 / dt));
      let localBW, bwFallback = false;
      if (bwTo >= bwFrom + BW_WIN) {
        const { start: quietRef, fallback } = findBestQuietWindow(total, bw_n, bwFrom, bwTo, BW_WIN);
        if (fallback) {
          // No valid quiet window found — fall back to nominal BW and flag it, rather than
          // averaging a window already judged invalid (FORCE-SCIENCE M1, mirrors CMJ Gate C).
          localBW = bw_n; bwFallback = true;
        } else if (forceCoreBodyweight?.bodyweightFromQuietWindow) {
          localBW = forceCoreBodyweight.bodyweightFromQuietWindow(total, quietRef, BW_WIN);
        } else {
          localBW = 0;
          for (let k = quietRef; k < quietRef + BW_WIN; k++) localBW += total[k];
          localBW /= BW_WIN;
        }
      } else {
        // Extremely short recording: use mean of entire standing phase
        const standN = Math.max(1, bwTo - bwFrom);
        if (forceCoreBodyweight?.meanForce) {
          localBW = forceCoreBodyweight.meanForce(total, bwFrom, standN);
        } else {
          localBW = 0;
          for (let k = bwFrom; k < bwFrom + standN; k++) localBW += total[k];
          localBW /= standN;
        }
      }
      const localMass = forceCoreBodyweight?.massFromBodyweight
        ? forceCoreBodyweight.massFromBodyweight(localBW, G)
        : localBW / G;

      // Velocity integration from firstOnPlate
      const intEnd = landing != null ? Math.min(n, landing + Math.round(1.0 / dt)) : n;
      const len = intEnd - firstOnPlate;
      const vel  = new Array(len).fill(0);
      const disp = new Array(len).fill(0);
      for (let k = 1; k < len; k++) {
        const ai = firstOnPlate + 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 - firstOnPlate; return k >= 0 && k < len ? vel[k]  : 0; };
      const dispAt = i => { const k = i - firstOnPlate; return k >= 0 && k < len ? disp[k] : 0; };

      // ── Propulsive onset detection ─────────────────────────────────────────
      // Backward search from the filtered peak-force sample on the FILTERED
      // signal; threshold = nominal BW + 5 N.
      //
      // Why filtered (not raw): raw noise spikes can create false sub-threshold
      // dips mid-ramp (especially for athletes with a gradual force onset),
      // causing the search to stop 30-50 ms too late inside the propulsive
      // phase.  The 24 Hz zero-phase filter smooths these out.
      //
      // Why bw_n + 5 N (not localBW): local_bw can be a few Newtons below bw_n
      // when the athlete holds a slight unload posture.  Using localBW as
      // threshold then lets the zero-phase filter's backward smear push the
      // detected onset into the standing-phase noise.  bw_n + 5 N sits safely
      // above standing oscillations for all athletes tested.
      let zeroCross = -1;
      { // Find peak force index in [firstOnPlate, takeoff)
        let peakK = firstOnPlate;
        for (let k = firstOnPlate + 1; k < takeoff; k++) {
          if (total[k] > total[peakK]) peakK = k;
        }
        const searchStart = Math.max(firstOnPlate, peakK - Math.round(0.010 / dt));
        const stopAt = Math.max(firstOnPlate + Math.round(0.050 / dt),
                                takeoff - Math.round(2.0 / dt));
        const onsetThresh = bw_n + 5.0;
        for (let k = searchStart; k >= stopAt; k--) {
          if (total[k] < onsetThresh) { zeroCross = k + 1; break; }
        }
      }
      // No propulsive threshold crossing → not a valid SJ (a step-off / walk-off never finds
      // one). REJECT — never fabricate zeroCross = takeoff − 0.5s, which turned non-jumps into
      // valid trials (FORCE-SCIENCE M1 §2.3).
      if (zeroCross < 0) continue;

      // Onset = zeroCross − 30ms backshift
      const onset = useOnsetBackshift
        ? Math.max(firstOnPlate, zeroCross - Math.round(0.030 / dt))
        : zeroCross;

      // CM contamination: deepest negative velocity in window before zeroCross
      const searchFrom = Math.max(firstOnPlate, takeoff - Math.round(3.0 / dt));
      let minVelPre = 0;
      for (let k = searchFrom; k < zeroCross; k++) {
        const v = velAt(k);
        if (v < minVelPre) minVelPre = v;
      }
      const cmContamination = minVelPre < -0.10;
      const cmVelDepth = Math.abs(Math.min(0, minVelPre));

      const phases = { onset, zeroCross, takeoff, landing };

      // Validity gate (FORCE-SCIENCE M1): reject non-jump events BEFORE computing metrics —
      // same caliber as computeSJMetrics (this trial's filtered total + localBW + dt over
      // [zeroCross, takeoff]). Deterministic invariants only, no empirical thresholds.
      let _propNetImpulse = 0;
      for (let i = zeroCross; i < takeoff; i++)
        _propNetImpulse += ((total[i] - localBW) + (total[i + 1] - localBW)) * 0.5 * dt;
      const _vTakeoff = velAt(takeoff);
      if (!(onset <= zeroCross && zeroCross < takeoff
            && Number.isFinite(_propNetImpulse) && _propNetImpulse > 0
            && Number.isFinite(_vTakeoff) && _vTakeoff > 0)) continue;

      // Self-consistent caliber (FORCE-SCIENCE M1 §2.3): pass localMass so net-impulse / v_con
      // / JH use the SAME local BW/mass baseline as the velocity integration above — was
      // nominal mass_kg, which made impulse jump height and takeoff velocity read off DIFFERENT
      // baselines. A device-compatible nominal value, if ever needed, gets an explicitly named
      // compatibility field — never a second silent baseline.
      const metrics = computeSJMetrics(time, total, left, right, velAt, dispAt, phases, localMass);
      jumps.push({
        index: jumps.length + 1, phases, metrics, vel, disp,
        quietRef: firstOnPlate, cmContamination, cmVelDepth: +cmVelDepth.toFixed(3),
        bwFallback,
      });
    }

    if (jumps.length === 0) throw new Error('未找到有效的 SJ 试次。请检查录制中是否包含清晰的腾空期。');
    return { jumps, filtTotal: total, filtLeft: left, filtRight: right };
  }

  // ── 4. SJ METRIC CALCULATOR ───────────────────────────────────────────────

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

    // Jump height from takeoff velocity (concentric net impulse method)
    const v_to = velAt(takeoff);
    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);

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

    const propulsiveTime = time[takeoff] - time[zeroCross];

    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 avgR     = (arr, a, b) => { let s = 0; for (let i = a; i <= b; i++) s += arr[i]; return s / (b - a + 1); };
    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++) s += ((arr[i] - bw_n) + (arr[i + 1] - bw_n)) * 0.5 * dt; return s; };
    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);
    };

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

    // Propulsive forces
    const peakPropForce     = maxR(total, zeroCross, takeoff);
    const avgPropForce      = avgR(total, zeroCross, takeoff);
    const relPeakPropForce  = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(peakPropForce, bw_n) : peakPropForce / bw_n * 100;
    const relAvgPropForce   = forceCoreBodyweight?.normalizeByBodyweight ? forceCoreBodyweight.normalizeByBodyweight(avgPropForce, bw_n) : avgPropForce  / bw_n * 100;

    // Impulse
    const propImpulse       = trapz(total, zeroCross, takeoff);
    const propNetImpulse    = trapzNet(total, zeroCross, takeoff);
    const relPropNetImpulse = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(propNetImpulse, mass_kg) : propNetImpulse / mass_kg;

    // RFD from zeroCross (propulsive onset) — time-window average slope
    const F_zc  = avgAround(total, zeroCross, 2);
    const rfdAt = 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);
    };
    const rfd50  = rfdAt(50);
    const rfd100 = rfdAt(100);
    const rfd150 = rfdAt(150);
    const rfd200 = rfdAt(200);
    const rfd250 = rfdAt(250);

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

    // Time to Peak Force (TTPF) — onset (zeroCross) to peak propulsive force index
    let peakForceIdx = zeroCross;
    for (let k = zeroCross; k <= takeoff; k++) {
      if (total[k] > total[peakForceIdx]) peakForceIdx = k;
    }
    const ttpf_ms = (time[peakForceIdx] - time[zeroCross]) * 1000;

    // Average RFD to Peak = (PeakF − F_onset) / TTPF (overall slope)
    const F_onset_sj = avgAround(total, zeroCross, 2);
    const avgRFDtoPeak = ttpf_ms > 0
      ? (peakPropForce - F_onset_sj) / (ttpf_ms / 1000)
      : null;

    // Power
    let peakPower = 0, sumPow = 0, propN = 0;
    for (let i = zeroCross; i < takeoff; i++) {
      const p = total[i] * velAt(i);
      if (p > peakPower) peakPower = p;
      sumPow += p; propN++;
    }
    const avgPropPower    = propN > 0 ? sumPow / 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;

    // L/R 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 asymProp = asymPct(zeroCross, takeoff);
    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 lProp = sideImp(left,  zeroCross, takeoff);
    const rProp = sideImp(right, zeroCross, takeoff);
    const asymPropImpulse = (lProp + rProp) > 0.1 ? (lProp - rProp) / (lProp + rProp) * 100 : 0;

    // Time to stabilization
    let timeToStab = null;
    if (landing !== null) {
      const stabThresh = 0.05 * bw_n, 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; }
      }
    }

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

    return {
      jumpHeight:          fmt(jumpHeight * 100, 1),
      jumpHeightFT:        flightTime != null ? fmt(jumpHeightFT * 100, 1) : null,
      takeoffVelocity:     fmt(v_to, 3),
      propulsiveTime:      fmt(propulsiveTime, 3),
      peakAcc:             fmt(peakAcc, 2),
      flightTime:          flightTime != null ? fmt(flightTime, 3) : null,
      peakPropForce:       fmt(peakPropForce, 1),
      avgPropForce:        fmt(avgPropForce, 1),
      relPeakPropForce:    fmt(relPeakPropForce, 1),
      relAvgPropForce:     fmt(relAvgPropForce, 1),
      propImpulse:         fmt(propImpulse, 1),
      propNetImpulse:      fmt(propNetImpulse, 1),
      relPropNetImpulse:   fmt(relPropNetImpulse, 2),
      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,
      peakRFD:             peakRFD != null ? fmtN(peakRFD) : null,
      ttpf:                fmtN(ttpf_ms),
      avgRFDtoPeak:        avgRFDtoPeak != null ? fmtN(avgRFDtoPeak) : null,
      peakPower:           fmtN(peakPower),
      avgPropPower:        fmtN(avgPropPower),
      relPeakPower:        fmt(relPeakPower, 1),
      relAvgPropPower:     fmt(relAvgPropPower, 1),
      asymProp:            fmt(asymProp, 1),
      asymPropImpulse:     fmt(asymPropImpulse, 1),
      timeToStab:          timeToStab != null ? fmt(timeToStab, 3) : null,
    };
  }

  // ── 5. CHART ─────────────────────────────────────────────────────────────

  function SJChart({ time, left, right, total, velAt, dispAt, phases, bw_n, mass_kg, overlays }) {
    const svgRef  = useRef(null);
    const stateRef = useRef({});
    const [viewRange, setViewRange] = useState(null);
    const [dragging, setDragging]   = useState(null);
    const [tooltip, setTooltip]     = useState(null);

    const ov = overlays || {};
    const W = 760, H = 300, ML = 62, MR = 20, MT = 24, MB = 40;
    const PW = W - ML - MR, PH = H - MT - MB;
    const clipId = 'sj-clip-' + Math.random().toString(36).slice(2, 6);

    const { onset, zeroCross, takeoff, landing } = phases;

    // View range defaults
    const t0raw  = time[Math.max(0, onset - Math.round(0.3 / (time[1] - time[0])))];
    const t1raw  = landing != null
      ? time[Math.min(time.length - 1, landing + Math.round(0.5 / (time[1] - time[0])))]
      : time[Math.min(time.length - 1, takeoff + Math.round(0.5 / (time[1] - time[0])))];
    const t0  = viewRange ? viewRange.t0  : t0raw;
    const t1  = viewRange ? viewRange.t1  : t1raw;
    const dt  = time[1] - time[0];
    const si  = Math.max(0, Math.round((t0 - time[0]) / dt));
    const ei  = Math.min(time.length - 1, Math.round((t1 - time[0]) / dt));

    // Force Y range
    let fMax = 0, fMin = 0;
    for (let i = si; i <= ei; i++) { if (total[i] > fMax) fMax = total[i]; if (total[i] < fMin) fMin = total[i]; }
    if (ov.vel)  { for (let i = si; i <= ei; i++) { const v = velAt(i) * 300; if (v > fMax) fMax = v; } }
    fMax = Math.ceil(fMax / 200) * 200 || 200;
    const curFMin = viewRange ? viewRange.fMin : fMin;
    const curFMax = viewRange ? viewRange.fMax : fMax;

    const xS  = t => ML + (t - t0) / (t1 - t0) * PW;
    const yF  = f => MT + PH * (1 - (f - curFMin) / (curFMax - curFMin));
    const yBW = yF(bw_n);

    // Overlay scales
    let vMin = 0, vMax = 0, dMin = 0, dMax = 0;
    if (ov.vel)  for (let i = si; i <= ei; i++) { const v = velAt(i); if (v > vMax) vMax = v; if (v < vMin) vMin = v; }
    if (ov.disp) for (let i = si; i <= ei; i++) { const d = dispAt(i); if (d > dMax) dMax = d; if (d < dMin) dMin = d; }
    const spaceA = yBW - MT, spaceB = MT + PH - yBW;
    const bwSc = (hi, lo) => Math.min(hi > 0 ? spaceA / hi : 1e9, lo < 0 ? spaceB / (-lo) : 1e9) * 0.95;
    const velSc  = bwSc(vMax, vMin),  dispSc = bwSc(dMax, dMin);
    const clip   = y => Math.max(MT, Math.min(MT + PH, y));
    const yV     = v => clip(yBW - v * velSc);
    const yD     = d => clip(yBW - d * dispSc);

    // Build SVG path for a signal
    const mkPath = (fn) => {
      let d = '';
      for (let i = si; i <= ei; i++) {
        const x = xS(time[i]).toFixed(1), y = yF(fn(i)).toFixed(1);
        d += (i === si ? 'M' : 'L') + x + ',' + y;
      }
      return d;
    };

    // Phase regions
    const phaseRegions = [
      { a: onset,     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)' }] : []),
    ];

    // Stateref for events
    stateRef.current = { viewRange, t0, t1, fMin: curFMin, fMax: curFMax };

    // Wheel zoom
    useEffect(() => {
      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, t0: ct0, t1: ct1, fMin: cfMin, fMax: cfMax } = stateRef.current;
        const curT0 = vr ? vr.t0 : ct0, curT1 = vr ? vr.t1 : ct1;
        const curFMin2 = vr ? vr.fMin : cfMin, curFMax2 = vr ? vr.fMax : cfMax;
        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 = curFMin2 + (1 - (sy - MT) / PH) * (curFMax2 - curFMin2);
        setViewRange({ t0: cursorT - (cursorT - curT0) * factor, t1: cursorT + (curT1 - cursorT) * factor, fMin: cursorF - (cursorF - curFMin2) * factor, fMax: cursorF + (curFMax2 - cursorF) * factor });
      };
      svgEl.addEventListener('wheel', onWheel, { passive: false });
      return () => svgEl.removeEventListener('wheel', onWheel);
    }, []);

    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, dySvg = (e.clientY - startCY) / rectH * H;
      const dtPx = (startRange.t1 - startRange.t0) / PW, 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);

    // Y-axis ticks
    const fRange = curFMax - curFMin;
    const fStep  = fRange > 3000 ? 1000 : fRange > 1500 ? 500 : fRange > 600 ? 200 : 100;
    const fTicks = [];
    for (let f = Math.ceil(curFMin / fStep) * fStep; f <= curFMax; f += fStep) fTicks.push(f);
    const fmtN = v => v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0);

    const mkMarker = (idx, color, label) => {
      if (idx < 0 || idx >= time.length) return null;
      const tx = time[idx];
      if (tx < t0 || tx > t1) return null;
      const x = xS(tx).toFixed(1);
      return (
        <g key={label}>
          <line x1={x} y1={MT} x2={x} y2={MT + PH} stroke={color} strokeWidth="1.5" strokeDasharray="4 2" />
          <text x={+x + 3} y={MT + 10} fontSize="9" fill={color} fontFamily="var(--font-mono)">{label}</text>
        </g>
      );
    };

    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` }] : []),
    ];
    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" />

        <g clipPath={`url(#${clipId})`}>
          {/* Phase backgrounds */}
          {phaseRegions.map(({ a, b, fill, label, lc }) => {
            const xa = xS(time[Math.max(a, si)]), xb = xS(time[Math.min(b, ei)]);
            const w  = Math.max(0, xb - xa);
            return (
              <g key={label}>
                <rect x={xa} y={MT} width={w} height={PH} fill={fill} />
                <line x1={xa} y1={MT} x2={xa} y2={MT + PH} stroke={lc} strokeWidth="1" />
              </g>
            );
          })}

          {/* BW line */}
          {yBW >= MT && yBW <= MT + PH && (
            <line x1={ML} y1={yBW.toFixed(1)} x2={ML + PW} y2={yBW.toFixed(1)}
              stroke="rgba(99,102,241,.35)" strokeWidth="1" strokeDasharray="6 3" />
          )}

          {/* Y grid */}
          {fTicks.map(f => (
            <line key={f} x1={ML} y1={yF(f).toFixed(1)} x2={ML + PW} y2={yF(f).toFixed(1)}
              stroke="var(--chart-grid)" strokeWidth="1" />
          ))}

          {/* Force traces */}
          <path d={mkPath(i => left[i])}  stroke="rgba(99,179,237,.45)" strokeWidth="1" fill="none" />
          <path d={mkPath(i => right[i])} stroke="rgba(251,146,60,.45)" strokeWidth="1" fill="none" />
          <path d={mkPath(i => total[i])} stroke="rgba(71,85,105,.85)"  strokeWidth="2" fill="none" />

          {/* Velocity overlay */}
          {ov.vel && (() => {
            let d = '';
            for (let i = si; i <= ei; i++) {
              const x = xS(time[i]).toFixed(1), y = yV(velAt(i)).toFixed(1);
              d += (i === si ? 'M' : 'L') + x + ',' + y;
            }
            return <path d={d} stroke="rgba(167,139,250,.85)" strokeWidth="1.5" fill="none" strokeDasharray="5 2" />;
          })()}

          {/* Displacement overlay */}
          {ov.disp && (() => {
            let d = '';
            for (let i = si; i <= ei; i++) {
              const x = xS(time[i]).toFixed(1), y = yD(dispAt(i)).toFixed(1);
              d += (i === si ? 'M' : 'L') + x + ',' + y;
            }
            return <path d={d} stroke="rgba(52,211,153,.75)" strokeWidth="1.4" fill="none" strokeDasharray="4 3" />;
          })()}

          {/* Phase markers */}
          {mkMarker(onset,     'rgba(52,211,153,.9)',  'onset')}
          {mkMarker(zeroCross, 'rgba(52,211,153,.7)',  'v=0')}
          {mkMarker(takeoff,   'rgba(167,139,250,.85)','TO')}
          {landing != null && mkMarker(landing, 'rgba(251,146,60,.7)', 'LD')}
        </g>

        {/* Y axis */}
        {fTicks.map(f => (
          <text key={f} x={ML - 5} y={yF(f) + 4} textAnchor="end" fontSize="9"
            fill="var(--muted)" fontFamily="var(--font-mono)">{fmtN(f)}</text>
        ))}
        <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="var(--chart-axis)" strokeWidth="1" />

        {/* X axis */}
        {(() => {
          const tRange = t1 - t0, step = tRange > 5 ? 1 : tRange > 2 ? 0.5 : 0.2;
          const ticks = [];
          for (let tv = Math.ceil(t0 / step) * step; tv <= t1; tv += step) {
            const x = xS(tv);
            ticks.push(
              <g key={tv}>
                <line x1={x.toFixed(1)} y1={MT + PH} x2={x.toFixed(1)} y2={MT + PH + 4} stroke="var(--chart-axis)" strokeWidth="1" />
                <text x={x.toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="9" fill="var(--muted)" fontFamily="var(--font-mono)">{tv.toFixed(1)}</text>
              </g>
            );
          }
          return ticks;
        })()}
        <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="var(--chart-axis)" strokeWidth="1" />
        <text x={ML + PW / 2} y={H - 2} textAnchor="middle" fontSize="9" fill="var(--muted)">Time (s)</text>
        <text x={10} y={MT + PH / 2} textAnchor="middle" fontSize="9" fill="var(--muted)"
          transform={`rotate(-90,10,${MT + PH / 2})`}>Force (N)</text>

        {/* Legend */}
        <g transform={`translate(${ML + 6},${MT + 8})`}>
          {legend.map(({ stroke, sw, dash, label }, li) => {
            const x = legX; legX += label.length * 6.2 + 28;
            return (
              <g key={li} transform={`translate(${x},0)`}>
                <line x1="0" y1="5" x2="18" y2="5" stroke={stroke} strokeWidth={sw} strokeDasharray={dash} />
                <text x="21" y="9" fontSize="9" fill="var(--muted)" fontFamily="var(--font-sans)">{label}</text>
              </g>
            );
          })}
        </g>

        {viewRange && (
          <text x={ML + PW - 4} y={MT - 6} textAnchor="end" fontSize="8" fill="var(--muted-2)"
            style={{ cursor: 'pointer' }} onClick={() => setViewRange(null)}>
            Reset zoom ×
          </text>
        )}
      </svg>
    );
  }

  // FL-2: duplication audit — this single-session detail view has no cards/table
  // dual-form duplication (unlike CMJ, which shows the same trial's metrics twice:
  // once as MCard grids, once as ComparisonTable's section-grouped rows, toggled
  // via 'cmj-metrics-view'). SJ renders exactly one card-grid form per section
  // below; no ComparisonTable-equivalent exists here. Conclusion: no toggle added.
  // ── 6. METRIC CARDS ──────────────────────────────────────────────────────

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

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

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

  // ── 7. TRIAL SELECTOR ────────────────────────────────────────────────────

  function TrialSelector({ jumps, selectedIdx, onSelect }) {
    return (
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        {jumps.map((j, idx) => {
          const active = idx === selectedIdx;
          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>
              {j.cmContamination && (
                <span style={{ marginTop: 1, fontSize: 9, fontWeight: 700, color: 'rgba(245,158,11,.9)', letterSpacing: '.02em' }}>⚠ CM</span>
              )}
            </button>
          );
        })}
      </div>
    );
  }

  // ── 8. ASYMMETRY BAR ─────────────────────────────────────────────────────

  function AsymBar({ label, value, cn }) {
    const abs = Math.abs(value);
    const leftDom = value > 0;
    const sev = abs < 5 ? { col: 'var(--pos)', glyph: '●' } : abs < 10 ? { col: 'var(--warn)', glyph: '◐' } : { col: 'var(--neg)', glyph: '⚠' };
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 12px', background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8 }}>
        {cn && <div style={{ fontSize: 10.5, color: 'var(--text-2)', fontWeight: 500 }}>{cn}</div>}
        <div style={{ fontSize: 9, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.04em' }}>{label}</div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontSize: 11, width: 22, textAlign: 'right', color: 'var(--muted)' }}>L</span>
          <div style={{ flex: 1, position: 'relative', height: 6, background: 'var(--panel-hi)', borderRadius: 3 }}>
            <div style={{
              position: 'absolute', top: 0, height: 6, borderRadius: 3,
              background: sev.col, opacity: 0.85,
              ...(leftDom ? { right: '50%', width: `${Math.min(50, abs / 2)}%` } : { left: '50%', width: `${Math.min(50, abs / 2)}%` }),
            }} />
            <div style={{ position: 'absolute', top: -1, left: '50%', transform: 'translateX(-50%)', width: 1, height: 8, background: 'var(--border-strong)' }} />
          </div>
          <span style={{ fontSize: 11, width: 22, color: 'var(--muted)' }}>R</span>
          <span className="mono" style={{ fontSize: 13, fontWeight: 600, color: sev.col, width: 50 }}>
            {sev.glyph} {abs.toFixed(1)}%
          </span>
          <span style={{ fontSize: 10, color: 'var(--muted)' }}>{leftDom ? 'L dom' : 'R dom'}</span>
        </div>
      </div>
    );
  }

  // ── 9. MAIN PANEL ─────────────────────────────────────────────────────────

  function SJPanel({ athletes = [], sjStore = {}, cmjStore = {}, onSaveSession = null, defaultAthleteId = null,
                     result: externalResult = null, onResultChange = null,
                     queuedFile = null, onQueuedFileProcessed = null }) {
    const fileRef  = useRef(null);
    const processingGenerationRef = useRef(0);
    const processingAthleteRef = useRef(defaultAthleteId);
    const [result,  setResult]  = useState(externalResult);
    const [loading, setLoading] = useState(false);
    const [error,   setError]   = useState(null);
    const [selectedIdx, setSelectedIdx] = useState(0);
    const [overlays, setOverlays] = useState({ vel: false, disp: false });
    const [backshift, setBackshift] = useState(true);
    const [dragOver, setDragOver] = useState(false);
    const [saveAthleteId, setSaveAthleteId] = useState(defaultAthleteId || (athletes[0]?.id ?? null));
    const [representativeIdx, setRepresentativeIdx] = useState(0);
    const [repUserChanged, setRepUserChanged] = useState(false);
    const [saveDate, setSaveDate] = useState('');

    useEffect(() => { setResult(externalResult); }, [externalResult]);
    useEffect(() => { if (defaultAthleteId != null) setSaveAthleteId(defaultAthleteId); }, [defaultAthleteId]);
    useEffect(() => {
      if (processingAthleteRef.current != null && processingAthleteRef.current !== defaultAthleteId) {
        processingGenerationRef.current += 1;
        setResult(null);
        if (onResultChange) onResultChange(null);
      }
      processingAthleteRef.current = defaultAthleteId;
    }, [defaultAthleteId, onResultChange]);
    useEffect(() => () => { processingGenerationRef.current += 1; }, []);
    useEffect(() => { setSaveDate(result?.meta?.date?.slice(0, 10) || new Date().toISOString().slice(0, 10)); }, [result]);

    const processFile = useCallback(async (file, intake = null) => {
      const generation = ++processingGenerationRef.current;
      setLoading(true); setError(null);
      try {
        const parsed = await parseVALDFile(file);
        const { meta, time, left, right } = parsed;
        if (intake) meta._intake = intake;
        const totalArr = left.map((l, i) => l + right[i]);
        const sampling = window.ForceCoreSampling?.samplingProfile(time, meta.frequency) || null;
        const sjResult = detectAllSJJumps(time, totalArr, left, right, meta.weight, backshift);
        const out = { meta, time,
          total: sjResult.filtTotal || totalArr,
          left:  sjResult.filtLeft  || left,
          right: sjResult.filtRight || right,
          jumps: sjResult.jumps, fileName: file.name, sampling };
        if (generation !== processingGenerationRef.current) return { ok: false, error: 'target_changed' };
        setResult(out);
        setSelectedIdx(0);
        setRepresentativeIdx(0);
        setRepUserChanged(false);
        if (onResultChange) onResultChange(out);
        return { ok: true, reviewRequired: true };
      } catch (err) {
        if (generation === processingGenerationRef.current) setError(err.message || '文件解析失败。');
        return { ok: false, error: err.message || '文件解析失败。' };
      } finally {
        if (generation === processingGenerationRef.current) setLoading(false);
      }
    }, [backshift, onResultChange]);

    const processedQueueItemRef = useRef(null);
    useEffect(() => {
      if (!queuedFile || queuedFile.type !== 'sj' || processedQueueItemRef.current === queuedFile.itemId) return;
      processedQueueItemRef.current = queuedFile.itemId;
      const intake = { version: 1, source: 'test_day_batch', originalFileName: queuedFile.file.name };
      processFile(queuedFile.file, intake).then(outcome => onQueuedFileProcessed?.(queuedFile.itemId, outcome));
    }, [queuedFile, onQueuedFileProcessed, processFile]);

    const onFileChange = (e) => {
      const file = e.target.files?.[0];
      if (file) processFile(file);
      e.target.value = '';
    };

    const onDrop = (e) => {
      e.preventDefault(); setDragOver(false);
      const file = e.dataTransfer.files?.[0];
      if (file) processFile(file);
    };

    // 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 = (cn, en) => (
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
        <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{cn}</span>
        {en && <MetricInfo formula={en} />}
      </div>
    );

    return (
      <main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <div style={{ width: 32, height: 32, borderRadius: 8, background: 'linear-gradient(135deg,#f59e0b,#d97706)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <polyline points="5 15 12 8 19 15" />
            </svg>
          </div>
          <div>
            <div style={{ fontSize: 17, fontWeight: 700, letterSpacing: '-.01em' }}>SJ Analysis</div>
            <div style={{ fontSize: 11, color: 'var(--muted)' }}>Squat Jump · 单纯推进期力-时间分析</div>
          </div>
        </div>

        {/* P5-1: 公共上传区壳 */}
        <ForceTestUploadZone
          fileRef={fileRef} hasResult={!!result} dragOver={dragOver}
          onDrop={onDrop} setDragOver={setDragOver} onFileChange={onFileChange}
          onReupload={() => fileRef.current?.click()}
        />

        {/* Options row */}
        <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--text-2)', cursor: 'pointer' }}>
            <input type="checkbox" checked={backshift} onChange={e => setBackshift(e.target.checked)} />
            <span>起跳时间 30ms 前移（Owen 2014 标准）</span>
          </label>
        </div>

        {/* Loading / Error */}
        <ForceTestLoadingError loading={loading} error={error} />

        {/* P2-B: Save to athlete banner */}
        <ForceTestSaveBanner
          result={result} athletes={athletes} athleteId={saveAthleteId} setAthleteId={setSaveAthleteId}
          sessionCount={sjStore[saveAthleteId]?.length || 0}
          trials={result?.jumps || []}
          selectedTrialIndex={representativeIdx}
          onSelectedTrialChange={idx => { setRepresentativeIdx(idx); setSelectedIdx(idx); setRepUserChanged(true); }}
          testType="sj"
          saveDate={saveDate} onSaveDateChange={setSaveDate}
          onSave={() => {
            if (!saveAthleteId || !result) return;
            const session = buildSJSession(result, result.fileName || '', representativeIdx, backshift, repUserChanged, saveDate);
            onSaveSession(saveAthleteId, session);
          }}
        />

        {result && (() => {
          const { meta, time, total, left, right, jumps } = result;
          const jump  = jumps[selectedIdx] || jumps[0];
          const { phases, metrics: m, vel, disp, quietRef, cmContamination, cmVelDepth } = jump;
          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; } })() : '';

          // P2B-3: SSC utilisation ratio — CMJ JH / SJ JH from same-day saved sessions
          const sscRatio = (() => {
            if (!saveAthleteId || !meta.date) return null;
            const cmjSessions = cmjStore[saveAthleteId] || [];
            const sjJH = m.jumpHeight;
            if (!sjJH || sjJH <= 0) return null;
            // Find CMJ session on the same calendar day
            const sjDay = meta.date.slice(0, 10);
            const match = cmjSessions.find(s => s.date && s.date.slice(0, 10) === sjDay);
            const forceSource = window.ForceSessionSource;
            const effectiveMatch = forceSource && typeof forceSource.resolveEffectiveSession === 'function'
              ? forceSource.resolveEffectiveSession(match) : match;
            if (!effectiveMatch || !effectiveMatch.best?.jumpHeight) return null;
            return { ratio: effectiveMatch.best.jumpHeight / sjJH, cmjJH: effectiveMatch.best.jumpHeight, sjJH };
          })();

          return (
            <>
              {/* Metadata row */}
              <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 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' }}>估算</span>}
                </span>
                <span><span className="mono" style={{ color: 'var(--text-2)' }}>{meta.frequency}</span> Hz</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>

              {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>
                  {' '}系统仍按中位采样间隔（{result.sampling.medianDtMs} ms）完成分析；指标仅供参考，并且不会进入跨会话趋势或比较。
                </div>
              )}

              {/* CM Contamination warning */}
              {cmContamination && (
                <div style={{ background: 'rgba(245,158,11,.08)', border: '1px solid rgba(245,158,11,.3)', borderRadius: 8, padding: '8px 14px', fontSize: 11, color: '#b45309', display: 'flex', alignItems: 'center', gap: 8 }}>
                  <span style={{ fontSize: 14 }}>⚠</span>
                  <span><strong>检测到反向动作（CM）污染</strong>：起跳前峰值负速度达 {cmVelDepth} m/s，该试次可能更接近 CMJ。建议重新确认测试规程。</span>
                </div>
              )}

              {/* Trial selector */}
              <TrialSelector jumps={jumps} selectedIdx={selectedIdx} onSelect={setSelectedIdx} />

              {/* Chart + overlay controls */}
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px' }}>
                <div style={{ display: 'flex', gap: 12, padding: '0 10px 6px', flexWrap: 'wrap', alignItems: 'center' }}>
                  <span style={{ fontSize: 9.5, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em', marginRight: 2 }}>{window.t('Overlay')}</span>
                  {[{ k: 'vel', l: 'Velocity' }, { k: 'disp', l: 'Displacement' }].map(({ k, l }) => (
                    <label key={k} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: 'var(--text-2)', cursor: 'pointer' }}>
                      <input type="checkbox" checked={overlays[k]} onChange={e => setOverlays(p => ({ ...p, [k]: e.target.checked }))} />
                      {l}
                    </label>
                  ))}
                  <span style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--muted-2)' }}>{window.t('Wheel zoom')} · {window.t('Drag pan')}</span>
                </div>
                <SJChart 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} />
              </div>

              {/* ── Metrics ──────────────────────────────────────────── */}
              <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>

                {section('结果 Outcome')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10 }}>
                  <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²/8\nT = flight time (s)', cite: 'Linthorne 2001' }} />}
                  <MCard cn="起跳速度" label="Takeoff Velocity" value={m.takeoffVelocity} unit="m/s" accent="var(--pos)"
                    info={{ formula: 'v = ∫(F−BW)/m dt\nfrom propulsive onset → takeoff', cite: 'McMahon 2018' }} />
                  <MCard cn="推进时长" label="Propulsive Time" value={m.propulsiveTime} unit="s" sub="v=0 → takeoff"
                    info={{ formula: 't_takeoff − t_zeroCross', cite: 'McMahon et al. 2018' }} />
                  <MCard cn="到达峰力时间" label="Time to Peak Force" value={m.ttpf} unit="ms" sub="Propulsive onset → Fmax"
                    info={{ formula: 'TTPF = t_peakF − t_onset\n(propulsive onset = v=0)', cite: 'Haff & Triplett 2015' }} />
                  <MCard cn="峰值加速度" label="Peak Acceleration" value={m.peakAcc} unit="m/s²" accent="rgba(251,191,36,.9)"
                    info={{ formula: 'a = (F−BW)/m, max during propulsion', cite: 'Standard kinematics' }} />
                  {m.flightTime != null && <MCard cn="腾空时间" label="Flight Time" value={m.flightTime} unit="s"
                    info={{ formula: 'T = t_landing − t_takeoff', cite: 'Standard' }} />}
                  {m.timeToStab != null && <MCard cn="落地稳定时间" label="Time to Stab." value={m.timeToStab} unit="s" sub="Landing ±5% BW"
                    info={{ formula: 'First 1 s window where |F−BW| < 5%BW', cite: 'Standard' }} />}
                </div>

                {section('力量 Force', '推进期 propulsive phase')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(120px,1fr))', gap: 6 }}>
                  <MCardSm cn="峰值推进力"   label="Peak Prop F"   value={m.peakPropForce}    unit="N"   accent="var(--pos)"
                    info={{ formula: 'max(GRF), v=0 → takeoff', cite: 'Hawkins Dynamics; McMahon 2018' }} />
                  <MCardSm cn="均值推进力"   label="Avg Prop F"    value={m.avgPropForce}     unit="N"
                    info={{ formula: 'mean(GRF), v=0 → takeoff', cite: 'McMahon 2018' }} />
                  <MCardSm cn="峰推 %BW"     label="Peak Prop %BW" value={m.relPeakPropForce} unit="%"   accent="var(--pos)"
                    info={{ formula: 'PeakPropF / BW × 100', cite: 'Hawkins Dynamics' }} />
                  <MCardSm cn="均推 %BW"     label="Avg Prop %BW"  value={m.relAvgPropForce}  unit="%"
                    info={{ formula: 'AvgPropF / BW × 100', cite: 'Hawkins Dynamics' }} />
                </div>

                {section('发力率 RFD', '推进起点（v=0）后各时间窗口均值斜率')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(110px,1fr))', gap: 6 }}>
                  <MCardSm cn="峰值瞬时 RFD" label="Peak RFD" value={m.peakRFD} unit="N/s" accent="rgba(248,113,113,.9)"
                    info={{ formula: 'max(ΔF/Δt), 10 ms rolling window\nover propulsive phase', cite: 'Haff & Triplett 2015' }} />
                  <MCardSm cn="均值 RFD（至峰）" label="Avg RFD to Peak" value={m.avgRFDtoPeak} unit="N/s"
                    info={{ formula: '(Fmax − F_onset) / TTPF\noverall slope onset→peak', cite: 'Haff & Triplett 2015' }} />
                  {[['0–50ms','rfd50'],['0–100ms','rfd100'],['0–150ms','rfd150'],['0–200ms','rfd200'],['0–250ms','rfd250']].map(([label, key]) => (
                    <MCardSm key={key} label={`RFD ${label}`} value={m[key]} unit="N/s"
                      info={{ formula: `ΔF(0→${label.replace('0–','')}) / Δt\nfrom propulsive onset`, cite: 'Owen et al. 2014' }} />
                  ))}
                </div>

                {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)"
                    info={{ formula: 'P = F × v, max during propulsion', cite: 'Hawkins Dynamics' }} />
                  <MCardSm cn="均值推进功率" label="Avg Prop Pwr"    value={m.avgPropPower}    unit="W"
                    info={{ formula: 'mean(F × v), propulsive phase', cite: 'Hawkins Dynamics' }} />
                  <MCardSm cn="相对峰功率"   label="Rel Peak Pwr"    value={m.relPeakPower}    unit="W/kg" accent="rgba(248,113,113,.9)"
                    info={{ formula: 'Peak Power / Body Mass', cite: 'Hawkins Dynamics' }} />
                  <MCardSm cn="相对均功率"   label="Rel Avg Pwr"     value={m.relAvgPropPower} unit="W/kg"
                    info={{ formula: 'Avg Prop Power / Body Mass', cite: 'Hawkins Dynamics' }} />
                </div>

                {section('冲量 Impulse')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(130px,1fr))', gap: 6 }}>
                  <MCardSm cn="推进冲量"       label="Prop Impulse"     value={m.propImpulse}       unit="N·s"
                    info={{ formula: '∫ GRF dt, onset → takeoff', cite: 'McMahon 2018' }} />
                  <MCardSm cn="推进净冲量"     label="Prop Net Impulse" value={m.propNetImpulse}    unit="N·s" accent="var(--pos)"
                    info={{ formula: '∫(F−BW) dt, onset → takeoff\n= m·v_takeoff', cite: 'McMahon 2018' }} />
                  <MCardSm cn="相对推进净冲量" label="Rel Prop Net Imp" value={m.relPropNetImpulse} unit="N·s/kg" accent="var(--pos)"
                    info={{ formula: 'Prop Net Impulse / Body Mass', cite: 'Hawkins Dynamics' }} />
                </div>

                {section('双侧不对称 Asymmetry')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(220px,1fr))', gap: 8 }}>
                  {m.asymProp != null && <AsymBar cn="推进力不对称" label="Propulsive Force Asymmetry" value={m.asymProp} />}
                  {m.asymPropImpulse != null && <AsymBar cn="推进冲量不对称" label="Propulsive Impulse Asymmetry" value={m.asymPropImpulse} />}
                </div>
                <div style={{ fontSize: 9.5, color: 'var(--muted)', lineHeight: 1.5, padding: '2px 4px' }}>
                  不对称公式：(L−R)/(L+R)×100%，正值 = 左侧优势 | Bilateral Symmetry Index (Chavda et al. 2019)
                </div>

                {/* P2B-3: SSC utilisation ratio */}
                {sscRatio && (() => {
                  const { ratio, cmjJH, sjJH } = sscRatio;
                  // SSC classification: <1.0 weak, 1.0–1.1 moderate, >1.1 good, >1.2 excellent
                  const ratioColor = ratio >= 1.2 ? 'var(--pos)' : ratio >= 1.0 ? 'rgba(16,185,129,.8)' : ratio >= 0.9 ? '#d97706' : 'var(--neg)';
                  const ratioLabel = ratio >= 1.2 ? '优秀' : ratio >= 1.0 ? '良好' : ratio >= 0.9 ? '偏低' : '受损';
                  return (
                    <>
                      {section('跨测试 Cross-Test', 'CMJ / SJ 当日对比')}
                      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10 }}>
                        <MCard
                          cn="SSC 利用率（CMJ/SJ JH 比）"
                          label="SSC Utilisation"
                          value={ratio.toFixed(2)}
                          unit=""
                          sub={`CMJ ${cmjJH} / SJ ${sjJH} cm`}
                          accent={ratioColor}
                          info={{ formula: 'SSC ratio = CMJ JH / SJ JH\n>1.10 = good SSC contribution', cite: 'Markovic 2007; Komi 1984' }}
                        />
                        <div style={{
                          background: 'var(--panel)', border: `1px solid var(--border)`, borderRadius: 10,
                          padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 6, justifyContent: 'center',
                        }}>
                          <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.05em' }}>判定</div>
                          <div style={{ fontSize: 18, fontWeight: 700, color: ratioColor }}>{ratioLabel}</div>
                          <div style={{ fontSize: 9.5, color: 'var(--muted)', lineHeight: 1.5 }}>
                            {'≥1.20 优秀 · ≥1.10 良好\n≥0.90 偏低 · <0.90 受损 SSC'}
                          </div>
                        </div>
                      </div>
                    </>
                  );
                })()}
              </div>
            </>
          );
        })()}
      </main>
    );
  }

  // ── P2-B: SJ Session Store ───────────────────────────────────────────────
  // Mirrors CMJ session store structure; stored per-athlete in localStorage.
  // Session object: { id, date, fileName, jumpCount, trials:[{index,metrics}], best, mean }

  // FORCE-WS-1b (2026-07-10): computed metrics carry a `formulaTip` (short formula
  // + 来源 when established), surfaced as a hover tip in surfaces we own. Direct-read
  // entries (raw peak推进力, 推进时长) get NO tip. Tips AUDITED against buildSJ /
  // metric computation above. Data-only — no rendering/logic here.
  const SJ_SUMMARY_METRICS = [
    { key: 'jumpHeight',         label: '跳跃高度',     unit: 'cm',    better: 'higher', formulaTip: '起跳速度² ÷ (2g)\n向心净冲量积分（冲量-动量法）' },
    { key: 'jumpHeightFT',       label: '跳高(飞行)',   unit: 'cm',    better: 'higher', formulaTip: 'g × 腾空时间² ÷ 8\n飞行时间法' },
    { key: 'takeoffVelocity',    label: '起跳速度',     unit: 'm/s',   better: 'higher', formulaTip: '起跳瞬时质心速度（净冲量积分 ÷ 质量）' },
    { key: 'propulsiveTime',     label: '推进时长',     unit: 's',     better: 'lower'  },
    { key: 'peakPropForce',      label: '峰值推进力',   unit: 'N',     better: 'higher' },
    { key: 'relPeakPropForce',   label: '峰推 %BW',     unit: '%',     better: 'higher', formulaTip: '峰值推进力 ÷ 体重 × 100%' },
    { key: 'propNetImpulse',     label: '推进净冲量',   unit: 'N·s',   better: 'higher', formulaTip: '∫(F − 体重)dt，推进相（梯形积分）' },
    { key: 'relPropNetImpulse',  label: '净冲量/kg',    unit: 'N·s/kg',better: 'higher', formulaTip: '推进净冲量 ÷ 质量' },
    { key: 'peakPower',          label: '峰值功率',     unit: 'W',     better: 'higher', formulaTip: '瞬时功率 F × v 的峰值' },
    { key: 'relPeakPower',       label: '相对峰功率',   unit: 'W/kg',  better: 'higher', formulaTip: '峰值功率 ÷ 质量' },
    { key: 'peakRFD',            label: '峰值 RFD',     unit: 'N/s',   better: 'higher', formulaTip: '10ms 滚动窗 dF/dt 的最大值\n来源：Haff et al. 2015' },
    { key: 'asymProp',           label: '推进不对称',   unit: '%',     better: null,     formulaTip: '(左 − 右) ÷ (左 + 右) × 100%，推进相\n来源：Bishop et al. 2018' },
  ];

  const copySJPersistedMetricScalars = (source) => {
    const metrics = {};
    Object.keys(source || {}).forEach(key => {
      const value = source[key];
      if (typeof value === 'number' && Number.isFinite(value)) metrics[key] = value;
    });
    return metrics;
  };

  function buildSJSession(result, fileName, representativeIdx = 0, backshift = true, repUserChanged = false, overrideDate = null) {
    const { meta, jumps, time, total } = result;
    const id   = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    const date = overrideDate || meta.date || new Date().toISOString().slice(0, 10);
    // FORCE-WS-4 (2026-07-12) · storage-shape retain (sanctioned edit, no compute change).
    // DATA-MODEL: new field `trial.curve = { t:[0→1], f:GRF/BW }` — the onset→takeoff force-
    // time samples the SJ panel already rendered at import (same total/bw_n as SJChart),
    // downsampled to 200 pts, mirroring CMJ's trial.curve so the shared analysis-face plotter
    // works. default = absent (old sessions have no curve → honest empty state).
    // migration = none (purely additive). rollback = drop the field (old code ignores unknown keys).
    // version = SJ session builder v2 (FORCE-WS-4). Metrics/detection UNCHANGED — values come
    // straight from j.metrics (computed in detectAllSJJumps); this only stops discarding the
    // already-computed curve samples.
    const bw_n = meta.weight * G;
    const CURVE_PTS = 200;
    const trials = jumps.map((j, i) => {
      const metrics = copySJPersistedMetricScalars(j.metrics);
      let curve = null;
      const ph = j.phases;
      if (time && total && ph && ph.onset != null && ph.takeoff != null && ph.takeoff > ph.onset) {
        const segStart = ph.onset, segEnd = ph.takeoff, segLen = segEnd - segStart + 1;
        const t0 = time[segStart], tSpan = (time[segEnd] - t0) || 1;
        const tOut = [], fOut = [];
        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(+((time[segStart + idx] - t0) / tSpan).toFixed(4));
          fOut.push(+(total[segStart + idx] / bw_n).toFixed(4));
        }
        curve = { t: tOut, f: fOut };
      }
      return { index: j.index ?? i + 1, metrics, curve,
               // Quality data persisted so the BW-fallback flag survives a reload (FORCE-SCIENCE
               // M1 audit): true when the quiet-window search failed and nominal BW was used.
               quality: { bwFallback: j.bwFallback ?? false } };
    });
    const best = {}, sessionMean = {};
    const metricDirections = new Map(SJ_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;
      sessionMean[key] = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 1000) / 1000;
      if      (better === 'higher') best[key] = Math.max(...vals);
      else if (better === 'lower')  best[key] = Math.min(...vals);
      else                          best[key] = sessionMean[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: sessionMean,
      representative,
      profileSource: { type: 'sj', trialIndex: representative.index, mode: repUserChanged ? 'manual' : 'auto' },
      provenance: { source: 'force_plate_upload', testType: 'sj', fileName, savedAt: new Date().toISOString(), ...(meta._intake ? { intake: { ...meta._intake } } : {}) },
      // Algorithm version governance (FORCE-SCIENCE M1 §2.6). version 'sj-2' = validity-gated
      // detection + self-consistent localMass caliber. Old sessions are never recomputed.
      algorithmRef: (() => {
        const sampling = (typeof window !== 'undefined' && window.ForceCoreSampling)
          ? window.ForceCoreSampling.samplingProfile(time, meta.frequency) : null;
        const external = !!sampling && sampling.source === 'external-precomputed';
        return {
          testType: 'sj',
          version: external
            ? 'external-precomputed'
            : sampling?.status === 'non-uniform' ? 'sj-2-nonuniform-median' : 'sj-2',
          onsetPolicy: external ? 'external-unknown' : (backshift ? 'propulsive-threshold-bwn+5N+30ms-backshift' : 'propulsive-threshold-bwn+5N'),
          filterPolicy: sampling ? sampling.filterApplied : 'unknown',
          sampling,
          generatedAt: new Date().toISOString(),
        };
      })(),
      protocol: {
        bodyMass: meta.weight,
        // effective (measured) rate; header declared kept separately (GPT audit P2.4).
        sampleRate: ((typeof window !== 'undefined' && window.ForceCoreSampling) ? window.ForceCoreSampling.samplingProfile(time, meta.frequency).effectiveHz : null) ?? null,
        declaredSampleRate: meta.frequency,
        onsetBackshiftMs: backshift ? 30 : 0,
        takeoffLandingSource: 'raw_force_threshold',
      },
    };
  }

  // ── P2-B: SJ Longitudinal View ───────────────────────────────────────────
  function SJLongitudinalView({ athlete, sessions, onBack, highlightSessionId = null }) {
    // Comparability rule (GPT audit P1.2): exclude rate-limited/invalid sessions from the trend.
    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);
    const [selectedKey, setSelectedKey] = useState('jumpHeight');
    const available = SJ_SUMMARY_METRICS.filter(m =>
      m.better !== null && sorted.some(s => s.best?.[m.key] != null)
    );
    const def = SJ_SUMMARY_METRICS.find(m => m.key === selectedKey) || SJ_SUMMARY_METRICS[0];
    const points = sorted.map(s => ({ x: s.date, y: s.best?.[selectedKey] })).filter(d => d.y != null);

    const W = 680, H = 200, ML = 52, MR = 12, MT = 12, MB = 34;
    const PW = W - ML - MR, PH = H - MT - MB;
    let yLo = Infinity, yHi = -Infinity;
    points.forEach(d => { if (d.y < yLo) yLo = d.y; if (d.y > yHi) yHi = d.y; });
    if (!isFinite(yLo) || yLo === yHi) { yLo = (yLo || 0) - 1; yHi = (yHi || 1) + 1; }
    const pad = (yHi - yLo) * 0.12;
    yLo -= pad; yHi += pad;
    const sx = i => ML + (i / Math.max(1, points.length - 1)) * PW;
    const sy = v => MT + PH - ((v - yLo) / (yHi - yLo)) * PH;
    const pathD = points.length < 2 ? '' : points.map((d, i) => `${i === 0 ? 'M' : 'L'} ${sx(i).toFixed(1)} ${sy(d.y).toFixed(1)}`).join(' ');
    // P3-C: MDC band
    const SJ_ICC_MAP = { jumpHeight: 'sj_jh', netImpulsePerKg: 'sj_netImpulse' };
    const sjMdcBand = (() => {
      if (points.length < 2) return null;
      const D = window.DASHBOARD_DATA;
      const iccKey = SJ_ICC_MAP[selectedKey];
      if (!iccKey) return null;
      const icc = (D.ICC_USER?.[iccKey] ?? D.ICC_DEFAULTS?.[iccKey]);
      if (icc == null) return null;
      const vals = points.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 = points[points.length - 1].y;
      return { yTop: sy(latestY + mdc / 2), yBot: sy(latestY - mdc / 2), mdc };
    })();

    return (
      <main style={{ flex: 1, minWidth: 0, overflowY: 'auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <button onClick={onBack} className="btn ghost" style={{ padding: '4px 10px', fontSize: 12 }}>← 返回</button>
          <div style={{ fontSize: 17, fontWeight: 700 }}>SJ 纵向追踪</div>
          {excludedCount > 0 && <div style={{ fontSize: 11, color: 'var(--danger, #b45309)', marginTop: 4 }}>⚠ {excludedCount} 个受限会话（采样率不足/时间轴异常）已排除出趋势比较</div>}
          {athlete && <span style={{ fontSize: 12, color: 'var(--muted)' }}>{athlete.name}</span>}
          <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--muted)' }}>{sessions.length} sessions · {sorted[0]?.date} → {sorted[sorted.length - 1]?.date}</span>
        </div>

        {/* Metric selector */}
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {available.map(m => (
            <button key={m.key} onClick={() => setSelectedKey(m.key)}
              className={`btn ${selectedKey === m.key ? '' : 'ghost'}`}
              style={{ fontSize: 11, padding: '3px 10px' }}>
              {m.label} <span style={{ opacity: .6, fontSize: 10 }}>{m.unit}</span>
            </button>
          ))}
        </div>

        {/* Trend chart */}
        {points.length < 2 ? (
          <div style={{ padding: '24px', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>至少需要 2 个 session 才能显示趋势图。</div>
        ) : (
          <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px', overflowX: 'auto' }}>
            <div style={{ padding: '0 10px 6px', fontSize: 11, fontWeight: 600, color: 'var(--text-2)' }}>
              {def.label} <span style={{ fontWeight: 400, color: 'var(--muted)', fontSize: 10 }}>({def.unit})</span>
            </div>
            <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: W, display: 'block', margin: '0 auto' }}>
              {/* Y gridlines */}
              {[0, .25, .5, .75, 1].map(t => {
                const yv = yLo + t * (yHi - yLo), y = sy(yv);
                return <g key={t}>
                  <line x1={ML} y1={y} x2={ML + PW} y2={y} stroke="var(--border)" strokeWidth=".5"/>
                  <text x={ML - 4} y={y + 4} textAnchor="end" fontSize={9} fill="var(--muted)">{yv.toFixed(yv >= 100 ? 0 : 2)}</text>
                </g>;
              })}
              {/* MDC band (P3-C) */}
              {sjMdcBand && (
                <g>
                  <rect x={ML} y={sjMdcBand.yTop} width={PW} height={sjMdcBand.yBot - sjMdcBand.yTop} fill="rgba(148,163,184,.13)" stroke="rgba(148,163,184,.25)" strokeWidth="0.5" strokeDasharray="3 2"/>
                  <text x={ML + PW - 2} y={sjMdcBand.yTop - 2} textAnchor="end" fontSize={8.5} fill="var(--muted-2)">MDC±{sjMdcBand.mdc.toFixed(2)}</text>
                </g>
              )}
              {/* Line */}
              <path d={pathD} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round"/>
              {/* Points + labels */}
              {points.map((d, i) => (
                <g key={i}>
                  <circle cx={sx(i)} cy={sy(d.y)} r={4} fill="var(--accent)" stroke="var(--panel)" strokeWidth="2"/>
                  <text x={sx(i)} y={MT + PH + MB - 4} textAnchor="middle" fontSize={8.5} fill="var(--muted)">{d.x.slice(5)}</text>
                  <text x={sx(i)} y={sy(d.y) - 8} textAnchor="middle" fontSize={9} fill="var(--text)" fontWeight={600}>{d.y}</text>
                </g>
              ))}
            </svg>
          </div>
        )}

        {/* Session table */}
        <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
          <div style={{ display: 'grid', gridTemplateColumns: '100px 1fr 80px 80px 80px', gap: 8, padding: '8px 14px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
            <span>日期</span><span>文件</span>
            <span style={{ textAlign: 'right' }}>JH (cm)</span>
            <span style={{ textAlign: 'right' }}>Net Imp/kg</span>
            <span style={{ textAlign: 'right' }}>Rel Pwr</span>
          </div>
          {[...sorted].reverse().map(s => (
            <div key={s.id} style={{ display: 'grid', gridTemplateColumns: '100px 1fr 80px 80px 80px', gap: 8, padding: '7px 14px', borderBottom: '1px solid rgba(15,23,42,.04)', fontSize: 12, alignItems: 'center', background: s.id === highlightSessionId ? 'var(--accent-soft)' : 'transparent', outline: s.id === highlightSessionId ? '1px solid rgba(59,130,246,.3)' : 'none' }}>
              <span className="mono" style={{ color: 'var(--muted)', fontSize: 11 }}>{s.date}</span>
              <span style={{ color: 'var(--text-2)', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.fileName || '—'}</span>
              <span className="mono" style={{ textAlign: 'right', color: 'var(--pos)' }}>{s.best?.jumpHeight?.toFixed(1) ?? '—'}</span>
              <span className="mono" style={{ textAlign: 'right' }}>{s.best?.relPropNetImpulse?.toFixed(2) ?? '—'}</span>
              <span className="mono" style={{ textAlign: 'right' }}>{s.best?.relPeakPower?.toFixed(1) ?? '—'}</span>
            </div>
          ))}
        </div>
      </main>
    );
  }

  // Update SJPanel to expose save functionality (used by app.jsx via onSaveSession)
  // The original SJPanel is already defined above; we patch the window export
  // to include the new components and buildSJSession so app.jsx can call them.
  window.SJPanel = SJPanel;
  window.SJLongitudinalView = SJLongitudinalView;
  window.buildSJSession = buildSJSession;
  window.SJ_SUMMARY_METRICS = SJ_SUMMARY_METRICS;
  window.__FORCE_TEST_INTERNALS__ = window.__FORCE_TEST_INTERNALS__ || {};
  window.__FORCE_TEST_INTERNALS__.sj = {
    detectAllSJJumps,
    computeSJMetrics,
    filtfilt,
    findBestQuietWindow,
  };
})();
