// imtp.jsx  v5  —  IMTP 力板分析：多 trial · F50~F300 · RFD · 不对称指数 · session 持久化
// 职责：算法（onset=bw_n+5×quietSD · 30ms 持续 · 无回溯）· 分析面板 · session 存储 · 纵向视图
// 依赖：无内部依赖
// Supported formats: VALD ForceDecks (.xlsx/.csv/.tsv), General CM format.
// Detects onset → peak. Computes force at 50–300ms, RFD, impulse, L/R asymmetry.
// Multi-trial: each pull separated by a return to baseline is a distinct trial.

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

  const G = 9.81;
  let forceCoreParser = null;
  const forceCoreParserOptions = { general: { includeWeightReliability: true } };
  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 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 (same formats as CMJ/SJ) ───────────────────────────────

  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, forceCoreParserOptions.general);
    let timeIncrement = 0.001, startDate = '', dataHeaderRow = -1, 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 = 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;
      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）。`);
    // Reliability: if the global min GRF < 80% of estimated BW, the recording never reached
    // quiet standing (e.g. MARS files starting mid pre-tension). Flag for manual entry.
    let globalMin = Infinity;
    for (let i = 0; i < total.length; i++) if (total[i] < globalMin) globalMin = total[i];
    const weightReliable = globalMin >= 0.80 * bestMean;
    const weightNote = weightReliable ? null
      : `最低力值 ${globalMin.toFixed(0)} N < 估算值 ${bestMean.toFixed(0)} N × 80%，录制可能未含安静站立段`;
    const weight_kg = forceCoreBodyweight?.massFromBodyweight ? forceCoreBodyweight.massFromBodyweight(bestMean, G) : bestMean / G;
    return { meta: { weight: +weight_kg.toFixed(2), weightEstimated: true, weightReliable, weightNote, frequency, date: startDate, athleteId: '' }, time, left, right };
  }

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

  function parseVALDFile(file) {
    if (forceCoreParser) return forceCoreParser.parseVALDFileParserOnly(file, forceCoreParserOptions);
    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);
  }

  // ── 3. IMTP DETECTION ────────────────────────────────────────────────────
  // For each trial: onset (force > BW + 5×SD sustained ≥30ms) → peak.
  // Multi-trial: after force returns below BW+threshold for ≥2s, a new trial begins.

  function detectAllIMTPTrials(time, totalRaw, leftRaw, rightRaw, mass_kg) {
    // 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 fs  = _sp ? _sp.effectiveHz : Math.round(1 / dt);
    const bw_n = mass_kg * G;

    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，以及文件头部体重是否正确。');

    // Estimate quiet standing SD from the first stable 500ms window; also capture mean
    // for cross-validation against the file-header weight (VALD files).
    const quietWin = Math.round(0.5 / dt);
    let quietSD = 5, quietMeanN = bw_n; // fallback to header BW if window too short
    { let ss = 0;
      const end = Math.min(firstOnPlate + quietWin, n);
      const mean = forceCoreBodyweight?.meanForce
        ? forceCoreBodyweight.meanForce(total, firstOnPlate, end - firstOnPlate)
        : (() => {
            let sum = 0;
            for (let i = firstOnPlate; i < end; i++) sum += total[i];
            return sum / (end - firstOnPlate);
          })();
      for (let i = firstOnPlate; i < end; i++) ss += (total[i] - mean) ** 2;
      quietSD = Math.max(3, Math.sqrt(ss / (end - firstOnPlate - 1)) || 5);
      quietMeanN = mean;
    }
    const estimatedMass = +(forceCoreBodyweight?.massFromBodyweight ? forceCoreBodyweight.massFromBodyweight(quietMeanN, G) : quietMeanN / G).toFixed(2);

    const onsetThresh = bw_n + 5 * quietSD;
    const onsetSustain = Math.round(0.030 / dt); // 30ms
    const relaxSustain = Math.round(2.0  / dt); // 2s gap between trials
    const searchFrom   = firstOnPlate + Math.round(0.5 / dt);

    const trials = [];
    let i = searchFrom;

    while (i < n) {
      // Find onset: force > onsetThresh for onsetSustain samples
      let onsetFound = -1;
      let consec = 0;
      while (i < n && onsetFound < 0) {
        if (total[i] > onsetThresh) {
          if (consec === 0 && i > searchFrom) { /* start candidate */ }
          if (++consec >= onsetSustain) { onsetFound = i - onsetSustain + 1; }
        } else { consec = 0; }
        i++;
      }
      if (onsetFound < 0) break;

      // Step 1: find contraction end — first sample after a minimum hold (300 ms)
      // where force drops back below onsetThresh. Bounding peak search here prevents
      // detecting end-of-recording artifacts as the peak (second-trial peak bug).
      const minHold = Math.round(0.3 / dt);
      const maxContraction = Math.round(10.0 / dt);
      // endFound = true → contractionEnd is the FIRST below-threshold (invalid) sample, so the
      // last valid contraction sample is contractionEnd−1. endFound = false → the pull never
      // dropped back (recording ended mid-contraction, or hit the 10 s cap), so contractionEnd
      // IS the last valid sample and must NOT be trimmed (GPT audit: truncated IMTP).
      let contractionEnd = Math.min(n - 1, onsetFound + maxContraction);
      let endFound = false;
      for (let k = onsetFound + minHold; k < contractionEnd; k++) {
        if (total[k] < onsetThresh) { contractionEnd = k; endFound = true; break; }
      }

      // Step 2: find peak WITHIN the contraction window only
      let peakIdx = onsetFound;
      for (let k = onsetFound; k <= contractionEnd; k++) {
        if (total[k] > total[peakIdx]) peakIdx = k;
      }

      // Step 3: find trial end — sustained 2 s below threshold after contraction end
      let trialEnd = contractionEnd;
      let relaxConsec = 0;
      for (let k = contractionEnd; k < Math.min(n, contractionEnd + Math.round(10.0 / dt)); k++) {
        if (total[k] < onsetThresh) {
          if (++relaxConsec >= relaxSustain) { trialEnd = k - relaxSustain; break; }
        } else { relaxConsec = 0; }
      }
      if (trialEnd <= contractionEnd) trialEnd = Math.min(n - 1, contractionEnd + Math.round(0.5 / dt));

      // Compute metrics
      const metrics = computeIMTPMetrics(time, total, left, right, onsetFound, peakIdx, contractionEnd, endFound, bw_n, mass_kg, dt, fs);
      trials.push({ index: trials.length + 1, phases: { onset: onsetFound, peak: peakIdx, contractionEnd, endFound, end: trialEnd }, metrics, quietSD });

      // Advance past this trial's end
      i = trialEnd + relaxSustain;
    }

    if (trials.length === 0) throw new Error('未检测到有效的 IMTP 试次。请确认录制中包含等长最大发力，且力值单位为 N。');
    return { trials, quietSD, estimatedMass };
  }

  // ── 4. IMTP METRIC CALCULATOR ─────────────────────────────────────────────

  function computeIMTPMetrics(time, total, left, right, onset, peakIdx, contractionEnd, endFound, bw_n, mass_kg, dt, fs) {
    const n = total.length;
    // Hard upper bound for EVERY scan/index below: no metric may read past this trial's
    // contraction end into a later trial's data (FORCE-SCIENCE M1 §2.4 — the cross-trial Peak
    // RFD contamination). contractionEnd is clamped defensively in case a caller omits it.
    contractionEnd = Number.isFinite(contractionEnd) ? Math.min(contractionEnd, n - 1) : n - 1;
    // The LAST valid contraction sample: when the detector FOUND the drop, contractionEnd is the
    // first below-threshold (invalid) sample, so the last valid one is contractionEnd−1; when it
    // did NOT (truncated recording / 10 s cap), contractionEnd itself is the last valid sample
    // and must not be trimmed (GPT audit-fix P2). Every windowed read below — including avg5's
    // ±2 span — stops here, so a later trial can't bleed in through the smoothing window
    // (GPT M1 audit P1: f100 1394→5074, Peak RFD →337400).
    const metricEnd = endFound ? Math.max(onset, contractionEnd - 1) : contractionEnd;

    // Peak force
    const peakForce    = total[peakIdx];
    const netPeakForce = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(peakForce, bw_n) : peakForce - bw_n;
    const relPeakForce    = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(peakForce, mass_kg) : peakForce    / mass_kg;
    const relNetPeakForce = forceCoreBodyweight?.normalizeByMass ? forceCoreBodyweight.normalizeByMass(netPeakForce, mass_kg) : netPeakForce / mass_kg;
    const timeToPeak_ms   = (time[peakIdx] - time[onset]) * 1000;

    // 5-point average helper (noise reduction)
    const avg5 = idx => {
      const a = Math.max(0, idx - 2), b = Math.min(n - 1, idx + 2, metricEnd);
      let s = 0; for (let k = a; k <= b; k++) s += total[k]; return s / (b - a + 1);
    };
    const avg5L = idx => {
      const a = Math.max(0, idx - 2), b = Math.min(n - 1, idx + 2, metricEnd);
      let s = 0; for (let k = a; k <= b; k++) s += left[k]; return s / (b - a + 1);
    };
    const avg5R = idx => {
      const a = Math.max(0, idx - 2), b = Math.min(n - 1, idx + 2, metricEnd);
      let s = 0; for (let k = a; k <= b; k++) s += right[k]; return s / (b - a + 1);
    };

    const F_onset = avg5(onset); // force at onset (5-pt average)

    // Force at time intervals from onset
    const forceAt = ms => {
      const idx = onset + Math.round(ms / 1000 / dt);
      if (idx > metricEnd) return null;
      return avg5(idx);
    };
    const netForceAt = ms => {
      const f = forceAt(ms);
      return f != null ? (forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(f, bw_n) : f - bw_n) : null;
    };

    // RFD from onset: ΔF / Δt (from onset)
    const rfdAt = ms => {
      const idx = onset + Math.round(ms / 1000 / dt);
      if (idx > metricEnd) return null;
      return (avg5(idx) - F_onset) / (ms / 1000);
    };
    const netRfdAt = ms => {
      const idx = onset + Math.round(ms / 1000 / dt);
      if (idx > metricEnd) return null;
      const fTarget = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(avg5(idx), bw_n) : avg5(idx) - bw_n;
      const fStart = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(F_onset, bw_n) : F_onset - bw_n;
      return (fTarget - fStart) / (ms / 1000);
    };

    // Net impulse from onset to time window
    const netImpulseAt = ms => {
      const idxEnd = Math.min(metricEnd, onset + Math.round(ms / 1000 / dt));
      let s = 0;
      for (let k = onset; k < idxEnd; k++) {
        const f0 = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(total[k], bw_n) : total[k] - bw_n;
        const f1 = forceCoreBodyweight?.netForce ? forceCoreBodyweight.netForce(total[k + 1], bw_n) : total[k + 1] - bw_n;
        s += (f0 + f1) * 0.5 * dt;
      }
      return s;
    };

    // L/R asymmetry at peak and at time intervals
    const asymAt = ms => {
      const idx = ms === 'peak' ? peakIdx : onset + Math.round(ms / 1000 / dt);
      if (idx > metricEnd) return null;
      const l = avg5L(idx), r = avg5R(idx), tot = l + r;
      return Math.abs(tot) > 1 ? (l - r) / tot * 100 : 0;
    };

    // Left/Right peak values
    const leftPeak  = avg5L(peakIdx);
    const rightPeak = avg5R(peakIdx);

    // 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;
    // Bounded to metricEnd (FORCE-SCIENCE M1 §2.4 + audit P1): was `< n`, which let a later
    // trial's fast pull overwrite THIS trial's Peak RFD (e.g. 3,500 → 123,200 N/s); metricEnd
    // (not contractionEnd) also keeps avg5's ±2 window out of the post-contraction samples.
    for (let k = onset; k + WIN_RFD <= metricEnd; k++) {
      const r = (avg5(k + WIN_RFD) - avg5(k)) / (WIN_RFD * dt);
      if (r > peakRFD) peakRFD = r;
    }
    peakRFD = peakRFD > 0 ? peakRFD : null;

    // Average RFD to Peak = the actual onset→peak slope (F_peak − F_onset) / TTPF
    // (FORCE-SCIENCE M1 §2.4): the numerator now shares the SAME onset baseline as TTPF's time
    // origin — was netPeakForce (peak − nominal BW), a different baseline than F_onset. If a
    // product ever needs netPeakForce/TTPF it gets its own explicitly named field.
    const avgRFDtoPeak = timeToPeak_ms > 0 ? (peakForce - F_onset) / (timeToPeak_ms / 1000) : null;

    // Epoch RFD: ΔF / Δt for each successive 50 ms band (Haff 2015)
    const epochRfd = (a_ms, b_ms) => {
      const ia = onset + Math.round(a_ms / 1000 / dt);
      const ib = onset + Math.round(b_ms / 1000 / dt);
      if (ia > metricEnd || ib > metricEnd) return null;
      return (avg5(ib) - avg5(ia)) / ((b_ms - a_ms) / 1000);
    };

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

    return {
      // Peak
      peakForce:       fmtN(peakForce),
      netPeakForce:    fmtN(netPeakForce),
      relPeakForce:    fmt(relPeakForce, 1),
      relNetPeakForce: fmt(relNetPeakForce, 1),
      timeToPeak:      fmtMs(timeToPeak_ms),
      leftPeak:        fmtN(leftPeak),
      rightPeak:       fmtN(rightPeak),

      // Force at intervals
      f50:  fmtN(forceAt(50)),    f100: fmtN(forceAt(100)),  f150: fmtN(forceAt(150)),
      f200: fmtN(forceAt(200)),   f250: fmtN(forceAt(250)),  f300: fmtN(forceAt(300)),

      // Net force at intervals
      nf50:  fmtN(netForceAt(50)),  nf100: fmtN(netForceAt(100)), nf150: fmtN(netForceAt(150)),
      nf200: fmtN(netForceAt(200)), nf250: fmtN(netForceAt(250)), nf300: fmtN(netForceAt(300)),

      // RFD from onset
      rfd50:  rfdAt(50)  != null ? fmtN(rfdAt(50))  : null,
      rfd100: rfdAt(100) != null ? fmtN(rfdAt(100)) : null,
      rfd150: rfdAt(150) != null ? fmtN(rfdAt(150)) : null,
      rfd200: rfdAt(200) != null ? fmtN(rfdAt(200)) : null,
      rfd250: rfdAt(250) != null ? fmtN(rfdAt(250)) : null,
      rfd300: rfdAt(300) != null ? fmtN(rfdAt(300)) : null,

      // Net RFD from onset
      nRfd50:  netRfdAt(50)  != null ? fmtN(netRfdAt(50))  : null,
      nRfd100: netRfdAt(100) != null ? fmtN(netRfdAt(100)) : null,
      nRfd200: netRfdAt(200) != null ? fmtN(netRfdAt(200)) : null,

      // Net impulse
      nimp100: fmt(netImpulseAt(100), 1),
      nimp200: fmt(netImpulseAt(200), 1),
      nimp300: fmt(netImpulseAt(300), 1),

      // Peak RFD & derived
      peakRFD:       peakRFD != null ? fmtN(peakRFD) : null,
      avgRFDtoPeak:  avgRFDtoPeak != null ? fmtN(avgRFDtoPeak) : null,

      // Epoch RFD (50 ms bands)
      eRfd_0_50:   epochRfd(0,   50)  != null ? fmtN(epochRfd(0,   50))  : null,
      eRfd_50_100: epochRfd(50, 100)  != null ? fmtN(epochRfd(50, 100))  : null,
      eRfd_100_150:epochRfd(100,150)  != null ? fmtN(epochRfd(100,150))  : null,
      eRfd_150_200:epochRfd(150,200)  != null ? fmtN(epochRfd(150,200))  : null,
      eRfd_200_250:epochRfd(200,250)  != null ? fmtN(epochRfd(200,250))  : null,
      eRfd_250_300:epochRfd(250,300)  != null ? fmtN(epochRfd(250,300))  : null,

      // Asymmetry
      asymPeak: fmt(asymAt('peak'), 1),
      asym100:  fmt(asymAt(100), 1),
      asym200:  fmt(asymAt(200), 1),
      asym300:  fmt(asymAt(300), 1),
    };
  }

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

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

    const { onset, peak, end: trialEnd } = phases;
    const ov  = overlays || {};
    const W = 760, H = 320, ML = 62, MR = 20, MT = 28, MB = 44;
    const PW = W - ML - MR, PH = H - MT - MB;
    const clipId = 'imtp-clip-' + Math.random().toString(36).slice(2, 6);
    const dt = time[1] - time[0];

    // Default view: onset−0.5s → peak+1s
    const t0raw = time[Math.max(0, onset - Math.round(0.5 / dt))];
    const t1raw = time[Math.min(time.length - 1, peak  + Math.round(1.5 / dt))];
    const t0 = viewRange ? viewRange.t0 : t0raw;
    const t1 = viewRange ? viewRange.t1 : t1raw;
    const si = Math.max(0, Math.round((t0 - time[0]) / dt));
    const ei = Math.min(time.length - 1, Math.round((t1 - time[0]) / dt));

    let fMax = bw_n, fMin = bw_n * 0.7;
    for (let i = si; i <= ei; i++) { if (total[i] > fMax) fMax = total[i]; if (total[i] < fMin) fMin = total[i]; }
    fMax = Math.ceil(fMax / 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);

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

    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 cfMin2 = vr ? vr.fMin : cfMin, cfMax2 = vr ? vr.fMax : cfMax;
        const factor = Math.pow(0.999, Math.max(-300, Math.min(300, e.deltaY)));
        const cursorT = curT0 + (sx - ML) / PW * (curT1 - curT0);
        const cursorF = cfMin2 + (1 - (sy - MT) / PH) * (cfMax2 - cfMin2);
        setViewRange({ t0: cursorT - (cursorT - curT0) * factor, t1: cursorT + (curT1 - cursorT) * factor, fMin: cursorF - (cursorF - cfMin2) * factor, fMax: cursorF + (cfMax2 - 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);

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

    // Time interval markers from onset
    const intervals = [50, 100, 150, 200, 250, 300];
    const intervalMarkers = intervals.map(ms => {
      const idx = onset + Math.round(ms / 1000 / dt);
      if (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={ms}>
          <line x1={x} y1={MT} x2={x} y2={MT + PH} stroke="rgba(148,163,184,.45)" strokeWidth="1" strokeDasharray="3 3" />
          <text x={+x + 2} y={MT + PH - 4} fontSize="8" fill="rgba(148,163,184,.8)" fontFamily="var(--font-mono)">{ms}</text>
        </g>
      );
    }).filter(Boolean);

    // Onset marker
    const onsetInView = time[onset] >= t0 && time[onset] <= t1;
    // Peak marker
    const peakInView  = time[peak]  >= t0 && time[peak]  <= t1;

    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})`}>
          {/* Pull region background */}
          {(() => {
            const xa = Math.max(ML, xS(time[onset]));
            const xb = Math.min(ML + PW, xS(time[Math.min(trialEnd, time.length - 1)]));
            const w  = Math.max(0, xb - xa);
            return <rect x={xa.toFixed(1)} y={MT} width={w.toFixed(1)} height={PH} fill="rgba(52,211,153,.07)" />;
          })()}

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

          {/* BW reference 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,.4)" strokeWidth="1.2" strokeDasharray="7 3" />
          )}

          {/* Onset threshold line */}
          {(() => {
            if (onsetThresh == null) return null;
            const y = yF(onsetThresh);
            if (y < MT || y > MT + PH) return null;
            return <line x1={ML} y1={y.toFixed(1)} x2={ML + PW} y2={y.toFixed(1)} stroke="rgba(245,158,11,.35)" strokeWidth="1" strokeDasharray="4 4" />;
          })()}

          {/* Time interval markers */}
          {intervalMarkers}

          {/* 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,.9)"   strokeWidth="2" fill="none" />

          {/* Onset marker */}
          {onsetInView && (() => {
            const x = xS(time[onset]).toFixed(1);
            return (
              <g>
                <line x1={x} y1={MT} x2={x} y2={MT + PH} stroke="rgba(52,211,153,.85)" strokeWidth="1.5" strokeDasharray="5 2" />
                <text x={+x + 4} y={MT + 12} fontSize="9.5" fontWeight="600" fill="rgba(52,211,153,.9)" fontFamily="var(--font-mono)">onset</text>
              </g>
            );
          })()}

          {/* Peak marker */}
          {peakInView && (() => {
            const x = xS(time[peak]).toFixed(1);
            const y = yF(total[peak]).toFixed(1);
            return (
              <g>
                <line x1={x} y1={MT} x2={x} y2={MT + PH} stroke="rgba(248,113,113,.75)" strokeWidth="1.5" strokeDasharray="5 2" />
                <text x={+x + 4} y={MT + 12} fontSize="9.5" fontWeight="600" fill="rgba(248,113,113,.9)" fontFamily="var(--font-mono)">Fmax</text>
                <circle cx={x} cy={y} r="4" fill="rgba(248,113,113,.8)" />
              </g>
            );
          })()}
        </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" />

        {/* BW label */}
        {yBW >= MT && yBW <= MT + PH && (
          <text x={ML - 5} y={yBW - 3} textAnchor="end" fontSize="8" fill="rgba(99,102,241,.6)" fontFamily="var(--font-mono)">BW</text>
        )}

        {/* 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 + 10})`}>
          {[
            { 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' },
            { stroke: 'rgba(99,102,241,.4)',  sw: 1, dash: '7 3', label: 'BW' },
            { stroke: 'rgba(148,163,184,.45)',sw: 1, dash: '3 3', label: 'Interval (ms)' },
          ].reduce((acc, { stroke, sw, dash, label }) => {
            const x = acc.x;
            acc.x += label.length * 6 + 28;
            acc.els.push(
              <g key={label} 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>
            );
            return acc;
          }, { x: 0, els: [] }).els}
        </g>

        {viewRange && (
          <text x={ML + PW - 4} y={MT - 8} 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 DID have an FL-T1
  // style two-form duplication: cumulative RFD (m.rfd50–rfd300) rendered both as
  // ForceProfileTable's RFD row and as the 累计 RFD MCardSm grid, stacked. Fixed
  // by a cards⇄table toggle persisted under the 'imtp-metrics-view' prefs domain
  // (see IMTPPanel). Force/Net-Force table rows and Net-RFD/Epoch-RFD/Net-Impulse
  // card grids have no second form and stay resident. 核心指标 highlight cards
  // (f100/f200 with citations) are intentional emphasis, not form duplication —
  // same treatment FL-T1 gave CMJ's cross-trial comparison rows.
  // ── 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({ trials, selectedIdx, onSelect }) {
    return (
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        {trials.map((t, 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' }}>Trial {t.index}</span>
              <span className="mono" style={{ fontSize: 15, fontWeight: 700, color: active ? 'var(--pos)' : 'var(--text-2)' }}>{t.metrics.peakForce} N</span>
              <span className="mono" style={{ fontSize: 10, color: 'var(--muted)' }}>{t.metrics.timeToPeak} ms</span>
            </button>
          );
        })}
      </div>
    );
  }

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

  function AsymBar({ label, value, cn }) {
    if (value == null) return null;
    const abs = Math.abs(value), leftDom = value > 0;
    const sev = abs < 5 ? { col: 'var(--pos)', g: '●' } : abs < 10 ? { col: 'var(--warn)', g: '◐' } : { col: 'var(--neg)', g: '⚠' };
    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.g} {abs.toFixed(1)}%</span>
          <span style={{ fontSize: 10, color: 'var(--muted)' }}>{leftDom ? 'L dom' : 'R dom'}</span>
        </div>
      </div>
    );
  }

  // ── 9. FORCE–TIME PROFILE TABLE ────────────────────────────────────────────
  // Compact table showing force & RFD at each time interval

  // FL-2: `showRFD` — the cumulative-RFD row duplicates the 累计 RFD MCardSm grid
  // (same m.rfd50–rfd300 values, two forms stacked). It is rendered here only when
  // the 'imtp-metrics-view' toggle is 'table'; the card grid renders when 'cards'.
  // Force / Net Force rows have no card counterpart and stay resident in both views.
  function ForceProfileTable({ m, showRFD = true }) {
    const intervals = [50, 100, 150, 200, 250, 300];
    const fKeys  = ['f50',  'f100',  'f150',  'f200',  'f250',  'f300'];
    const nfKeys = ['nf50', 'nf100', 'nf150', 'nf200', 'nf250', 'nf300'];
    const rfdKeys = ['rfd50','rfd100','rfd150','rfd200','rfd250','rfd300'];

    return (
      <div style={{ overflowX: 'auto' }}>
        <table style={{ borderCollapse: 'collapse', fontSize: 11, minWidth: 500 }}>
          <thead>
            <tr>
              <th style={{ padding: '5px 10px', textAlign: 'left', color: 'var(--muted)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 9.5, textTransform: 'uppercase', letterSpacing: '.04em' }}>时间点</th>
              {intervals.map(ms => (
                <th key={ms} style={{ padding: '5px 10px', textAlign: 'right', color: 'var(--muted)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 9.5, fontFamily: 'var(--font-mono)' }}>{ms} ms</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {[
              { label: '总力 Force (N)',      keys: fKeys,   accent: null },
              { label: '净力 Net Force (N)',  keys: nfKeys,  accent: 'var(--pos)' },
              ...(showRFD ? [{ label: 'RFD (N/s)', keys: rfdKeys, accent: 'rgba(248,113,113,.9)' }] : []),
            ].map(({ label, keys, accent }) => (
              <tr key={label}>
                <td style={{ padding: '5px 10px', color: 'var(--text-2)', whiteSpace: 'nowrap', borderBottom: '1px solid var(--border)' }}>{label}</td>
                {keys.map((k, i) => (
                  <td key={i} style={{ padding: '5px 10px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: accent || 'var(--text)', fontWeight: 500, borderBottom: '1px solid var(--border)' }}>
                    {m[k] ?? '—'}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    );
  }

  // ── 10. MAIN PANEL ────────────────────────────────────────────────────────

  function IMTPPanel({ athletes = [], imtpStore = {}, onSaveSession = null, defaultAthleteId = null, result: externalResult = null, onResultChange = null,
                       queuedFile = null, onQueuedFileProcessed = null }) {
    const [saveAthleteId, setSaveAthleteId] = useState(defaultAthleteId || (athletes[0]?.id ?? 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 [dragOver, setDragOver] = useState(false);
    const [representativeIdx, setRepresentativeIdx] = useState(0);
    const [repUserChanged, setRepUserChanged] = useState(false);
    const [saveDate, setSaveDate] = useState('');

    // FL-2: cumulative-RFD presentation form — 'cards' (累计 RFD MCardSm grid)
    // ⇄ 'table' (RFD row inside ForceProfileTable). Same duplicated m.rfd50–rfd300
    // values, one form at a time; persisted via window.VizPanelPrefsRepo,
    // 'imtp-metrics-view' domain ({ view }), mirroring FL-T1's cmj-metrics-view.
    const metricsViewRepo = React.useMemo(() => window.VizPanelPrefsRepo || null, []);
    const [metricsView, setMetricsView] = useState(() => {
      if (metricsViewRepo) return metricsViewRepo.load('imtp-metrics-view').view;
      return 'cards';
    });
    const updateMetricsView = view => {
      setMetricsView(view);
      if (metricsViewRepo) metricsViewRepo.save('imtp-metrics-view', { view });
    };

    // Weight override — used when auto-estimation is unreliable (MARS-style files)
    const [editingWeight,  setEditingWeight]  = useState(false);
    const [weightInput,    setWeightInput]    = useState('');
    const [weightManualOk, setWeightManualOk] = useState(true);

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

    // On new file load: auto-open edit input if weight is unreliable
    useEffect(() => {
      if (!result) return;
      if (result.meta.weightEstimated && result.meta.weightReliable === false) {
        setEditingWeight(true);
        setWeightInput('');
        setWeightManualOk(false);
      } else {
        setEditingWeight(false);
        setWeightManualOk(true);
      }
    }, [result?.fileName]); // eslint-disable-line react-hooks/exhaustive-deps

    // Recompute BW-dependent metrics with a new body mass (triggered by manual weight entry).
    // Deliberately does NOT re-run onset/peak detection — for MARS-style pre-tension files
    // the recording never reaches actual BW level, so firstOnPlate detection would fail.
    // Onset timing stays from the original run; only net/relative force metrics are updated.
    const recalcWithMass = useCallback((newMass) => {
      if (!result || !(newMass > 0)) return;
      try {
        const bw_n_new = newMass * G;
        const dt = result.sampling?.medianDtMs != null
          ? result.sampling.medianDtMs / 1000
          : result.time[1] - result.time[0];
        const fs = result.sampling?.effectiveHz || Math.round(1 / dt);
        const newTrials = result.trials.map(trial => {
          const { onset, peak, contractionEnd, endFound } = trial.phases;
          const newMetrics = computeIMTPMetrics(
            result.time, result.total, result.left, result.right,
            onset, peak, contractionEnd, endFound !== false, bw_n_new, newMass, dt, fs
          );
          return { ...trial, metrics: newMetrics };
        });
        const newResult = {
          ...result, trials: newTrials,
          meta: { ...result.meta, weight: newMass, weightReliable: true, weightNote: null },
        };
        setResult(newResult);
        if (onResultChange) onResultChange(newResult);
        setWeightManualOk(true);
        setEditingWeight(false);
      } catch (err) { setError(err.message); }
    }, [result, onResultChange]);

    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 { trials, quietSD, estimatedMass } = detectAllIMTPTrials(time, totalArr, left, right, meta.weight);
        const out = { meta, time, total: totalArr, left, right, trials, quietSD, estimatedMass, 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);
      }
    }, [onResultChange]);

    const processedQueueItemRef = useRef(null);
    useEffect(() => {
      if (!queuedFile || queuedFile.type !== 'imtp' || 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,#8b5cf6,#6d28d9)', 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="22 7 13.5 15.5 8.5 10.5 2 17" />
              <polyline points="16 7 22 7 22 13" />
            </svg>
          </div>
          <div>
            <div style={{ fontSize: 17, fontWeight: 700, letterSpacing: '-.01em' }}>IMTP Analysis</div>
            <div style={{ fontSize: 11, color: 'var(--muted)' }}>Isometric Mid-Thigh Pull · 等长中段拉力分析</div>
          </div>
        </div>

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

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

        {/* Save banner */}
        <ForceTestSaveBanner
          result={result} athletes={athletes} athleteId={saveAthleteId} setAthleteId={setSaveAthleteId}
          sessionCount={imtpStore[saveAthleteId]?.length || 0}
          trials={result?.trials || []}
          selectedTrialIndex={representativeIdx}
          onSelectedTrialChange={idx => { setRepresentativeIdx(idx); setSelectedIdx(idx); setRepUserChanged(true); }}
          testType="imtp"
          saveDate={saveDate} onSaveDateChange={setSaveDate}
          onSave={() => {
            if (!saveAthleteId || !result) return;
            if (!weightManualOk) return; // block save until weight is confirmed
            const session = buildIMTPSession(result, result.fileName || '', representativeIdx, repUserChanged, saveDate);
            onSaveSession(saveAthleteId, session);
          }}
        />

        {result && (() => {
          const { meta, time, total, left, right, trials, quietSD } = result;
          const trial  = trials[selectedIdx] || trials[0];
          const { phases, metrics: m } = trial;
          const onsetThresh = meta.weight * G + 5 * (trial.quietSD || quietSD);
          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 (
            <>
              {/* Metadata row */}
              <div style={{ display: 'flex', gap: 16, fontSize: 11, color: 'var(--muted)', flexWrap: 'wrap', alignItems: 'center' }}>
                {dateStr && <span>{dateStr}</span>}

                {/* ── Weight cell ─────────────────────────────────────────── */}
                <span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
                  <span>体重</span>

                  {/* Unreliable: hide numeric value, show dash */}
                  {meta.weightEstimated && meta.weightReliable === false && !weightManualOk
                    ? <span style={{ color: 'var(--warn)', fontWeight: 600 }}>—</span>
                    : <span className="mono" style={{ color: 'var(--text-2)' }}>{meta.weight} kg</span>
                  }

                  {/* Source badge */}
                  {!meta.weightEstimated && (
                    <span style={{ fontSize: 9.5, fontWeight: 600, background: 'rgba(37,99,235,.08)', color: '#2563eb', border: '1px solid rgba(37,99,235,.2)', borderRadius: 3, padding: '1px 5px' }}>文件</span>
                  )}
                  {meta.weightEstimated && (meta.weightReliable !== false || weightManualOk) && (
                    <span style={{ 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' }}>
                      {weightManualOk && meta.weightReliable === false ? '手动' : '估算'}
                    </span>
                  )}
                  {meta.weightEstimated && meta.weightReliable === false && !weightManualOk && (
                    <span style={{ fontSize: 9.5, fontWeight: 600, background: 'rgba(239,68,68,.10)', color: 'var(--neg)', border: '1px solid rgba(239,68,68,.3)', borderRadius: 3, padding: '1px 5px' }}>⚠ 需手动输入</span>
                  )}

                  {/* VALD cross-check: compare header vs force-signal estimate */}
                  {!meta.weightEstimated && result.estimatedMass && (() => {
                    const diff = Math.abs(result.estimatedMass - meta.weight) / meta.weight;
                    const ok   = diff <= 0.05;
                    return (
                      <span style={{ fontSize: 10, color: ok ? 'var(--pos)' : 'var(--warn)', marginLeft: 2 }}
                            title={ok ? '文件体重与力板估算一致' : '文件体重与力板估算差异 > 5%，请核查文件头'}>
                        {ok ? '✓' : '⚠'} 估算 {result.estimatedMass} kg
                      </span>
                    );
                  })()}

                  {/* Edit button — always available */}
                  <button className="btn ghost" style={{ padding: '1px 6px', fontSize: 10, marginLeft: 2 }}
                    onClick={() => { setWeightInput(String(meta.weight || '')); setEditingWeight(true); }}>
                    编辑
                  </button>
                </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' }}>{trials.length} trial{trials.length > 1 ? 's' : ''}</span>
                <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>起跳阈值 = BW + 5×SD ({onsetThresh.toFixed(0)} N)</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>
              )}

              {/* ── Weight input panel: shown when unreliable or when editing ── */}
              {(editingWeight || (meta.weightEstimated && meta.weightReliable === false && !weightManualOk)) && (
                <div style={{ background: 'rgba(245,158,11,.06)', border: '1px solid rgba(245,158,11,.3)', borderRadius: 8, padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
                  {/* Reason note — only for unreliable estimation */}
                  {meta.weightEstimated && meta.weightReliable === false && !weightManualOk && (
                    <div style={{ fontSize: 11, color: '#b45309', lineHeight: 1.5 }}>
                      <strong>⚠ 体重无法自动估算：</strong>{meta.weightNote}
                    </div>
                  )}
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                    <span style={{ fontSize: 12, color: 'var(--text-2)' }}>
                      {meta.weightEstimated && meta.weightReliable === false && !weightManualOk ? '请输入体重' : '修改体重'}
                    </span>
                    <input
                      type="number" step="0.1" min="30" max="200"
                      value={weightInput}
                      onChange={e => setWeightInput(e.target.value)}
                      onKeyDown={e => { if (e.key === 'Enter') { const v = parseFloat(weightInput); if (v > 0) recalcWithMass(v); } }}
                      placeholder="如：61.0"
                      autoFocus={meta.weightReliable === false && !weightManualOk}
                      style={{ width: 80, fontFamily: 'var(--font-mono)', fontSize: 12,
                               border: '1px solid rgba(245,158,11,.6)', borderRadius: 4,
                               padding: '3px 8px', background: 'var(--panel)', color: 'var(--text)' }}
                    />
                    <span style={{ fontSize: 12, color: 'var(--muted)' }}>kg</span>
                    <button className="btn primary" style={{ padding: '4px 12px', fontSize: 11 }}
                      disabled={!(parseFloat(weightInput) > 0)}
                      onClick={() => { const v = parseFloat(weightInput); if (v > 0) recalcWithMass(v); }}>
                      确认并重算
                    </button>
                    {/* Cancel only available for voluntary edits, not for forced entry */}
                    {editingWeight && !(meta.weightEstimated && meta.weightReliable === false && !weightManualOk) && (
                      <button className="btn ghost" style={{ padding: '4px 8px', fontSize: 11 }}
                        onClick={() => setEditingWeight(false)}>取消</button>
                    )}
                  </div>
                  {meta.weightEstimated && meta.weightReliable === false && !weightManualOk && (
                    <span style={{ fontSize: 10, color: 'var(--neg)' }}>保存前必须先确认体重</span>
                  )}
                </div>
              )}

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

              {/* Chart */}
              <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px' }}>
                <div style={{ padding: '0 10px 4px', fontSize: 10, color: 'var(--muted-2)' }}>滚轮缩放 · 拖拽平移</div>
                <IMTPChart
                  time={time} left={left} right={right} total={total}
                  phases={phases}
                  bw_n={meta.weight * G}
                  onsetThresh={onsetThresh}
                  mass_kg={meta.weight}
                  overlays={{}}
                />
              </div>

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

                {/* P2C-2: 核心指标置首 — 循证优先级：相对峰力(N/kg) → F100 → F200 → 净峰力（Ch.12：时间点力量比时间窗 RFD 可靠性更高，ICC > 0.90） */}
                {section('核心指标 Key Metrics', 'Ch.12 · F100/F200 ICC > 0.90；Driveline：Rel Net F > 3000N/kg 为力量转换阈值')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10 }}>
                  <MCard cn="相对峰力"          label="Relative Peak F"   value={m.relPeakForce}    unit="N/kg" accent="var(--pos)"
                    info={{ formula: 'Fmax / body mass (kg)', cite: 'Comfort 2019; VALD V2.0; Driveline: >3000 N/kg 为力量优先级转换阈值' }} />
                  <MCard cn="100ms 时间点力"    label="Force @ 100ms"     value={m.f100}            unit="N"    sub="From onset"            accent="rgba(59,130,246,.9)"
                    info={{ formula: 'F_100 = total force at t_onset + 100ms', cite: 'Haff & Triplett 2015; Ch.12: ICC > 0.90，比时间窗 RFD 更可靠' }} />
                  <MCard cn="200ms 时间点力"    label="Force @ 200ms"     value={m.f200}            unit="N"    sub="From onset"            accent="rgba(59,130,246,.9)"
                    info={{ formula: 'F_200 = total force at t_onset + 200ms', cite: 'Haff & Triplett 2015; Ch.12: ICC > 0.90' }} />
                  <MCard cn="峰值净力"          label="Net Peak Force"    value={m.netPeakForce}    unit="N"    sub="Fmax − BW"             accent="var(--pos)"
                    info={{ formula: 'Net Fmax = Fmax − BW', cite: 'Comfort 2019; VALD ForceDecks V2.0' }} />
                </div>

                {section('力量参数 Force Parameters')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10 }}>
                  <MCard cn="峰值总力（Fmax）"  label="Peak Force"        value={m.peakForce}       unit="N"    sub="Total GRF at peak"     accent="var(--pos)"
                    info={{ formula: 'Fmax = max(total force)\nfrom onset → contraction end', cite: 'VALD ForceDecks V2.0; Haff & Triplett 2015' }} />
                  <MCard cn="相对净峰力"        label="Rel Net Peak F"    value={m.relNetPeakForce} unit="N/kg" sub="(Fmax−BW)/kg"          accent="var(--pos)"
                    info={{ formula: '(Fmax − BW) / body mass (kg)', cite: 'VALD ForceDecks V2.0' }} />
                  <MCard cn="到达峰值时间"      label="Time to Peak"      value={m.timeToPeak}      unit="ms"
                    info={{ formula: 'TTPF = t_peak − t_onset (ms)', cite: 'Haff & Triplett 2015; Comfort 2019' }} />
                  <MCard cn="左侧峰力"          label="Left Peak"         value={m.leftPeak}        unit="N"    sub="At peak index"
                    info={{ formula: 'Left GRF at peak index\n(5-point average)', cite: 'VALD ForceDecks V2.0' }} />
                  <MCard cn="右侧峰力"          label="Right Peak"        value={m.rightPeak}       unit="N"    sub="At peak index"
                    info={{ formula: 'Right GRF at peak index\n(5-point average)', cite: 'VALD ForceDecks V2.0' }} />
                </div>

                {section('峰值发力率 Peak RFD')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(150px,1fr))', gap: 10 }}>
                  <MCard 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\nfrom onset to end of contraction', cite: 'Haff & Triplett 2015' }} />
                  <MCard cn="平均 RFD（到峰力）" label="Avg RFD to Peak" value={m.avgRFDtoPeak} unit="N/s"
                    info={{ formula: 'Avg RFD = netPeakForce / TTPF\n(net force overall slope)', cite: 'Haff & Triplett 2015; Comfort 2019' }} />
                </div>

                {/* FL-2: cumulative RFD (rfd50–rfd300) was duplicated — shown both as the */}
                {/* RFD row of ForceProfileTable and as the 累计 RFD card grid below. Now one */}
                {/* form at a time via the 'imtp-metrics-view' toggle (FL-T1 pattern). Force / */}
                {/* Net Force table rows have no card counterpart and stay resident always. */}
                <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                  {section('力-时间曲线 Force–Time Profile', '从起跳时刻起')}
                  <div style={{ display: 'inline-flex', background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 8, padding: 2, gap: 2 }}
                    role="tablist" aria-label="累计 RFD 呈现形式">
                    {[['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>
                <ForceProfileTable m={m} showRFD={metricsView === 'table'} />

                {metricsView === 'cards' && (<>
                {section('累计 RFD Cumulative RFD', '从 onset 至各时间节点')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(110px,1fr))', gap: 6 }}>
                  {[['0–50ms','rfd50'],['0–100ms','rfd100'],['0–150ms','rfd150'],['0–200ms','rfd200'],['0–250ms','rfd250'],['0–300ms','rfd300']].map(([label, key]) => (
                    <MCardSm key={key} label={`RFD ${label}`} value={m[key]} unit="N/s" accent="rgba(248,113,113,.9)"
                      info={{ formula: 'RFD = (F_t − F_onset) / t\ncumulative from onset', cite: 'Haff & Triplett 2015' }} />
                  ))}
                </div>
                </>)}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(110px,1fr))', gap: 6 }}>
                  {[['0–50ms','nRfd50'],['0–100ms','nRfd100'],['0–200ms','nRfd200']].map(([label, key]) => (
                    <MCardSm key={key} label={`Net RFD ${label}`} value={m[key]} unit="N/s"
                      info={{ formula: 'Net RFD = Δ(F−BW) / Δt\ncumulative from onset', cite: 'Haff & Triplett 2015' }} />
                  ))}
                </div>

                {section('分段 RFD Epoch RFD', '每 50ms 区间瞬时发力率')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(110px,1fr))', gap: 6 }}>
                  {[
                    ['0–50ms',   'eRfd_0_50'],
                    ['50–100ms', 'eRfd_50_100'],
                    ['100–150ms','eRfd_100_150'],
                    ['150–200ms','eRfd_150_200'],
                    ['200–250ms','eRfd_200_250'],
                    ['250–300ms','eRfd_250_300'],
                  ].map(([label, key]) => (
                    <MCardSm key={key} label={`Epoch ${label}`} value={m[key]} unit="N/s" accent="rgba(139,92,246,.9)"
                      info={{ formula: 'ΔF/Δt = (F_b − F_a) / 0.05s\nper 50ms epoch from onset', cite: 'Haff & Triplett 2015; Chavda 2015' }} />
                  ))}
                </div>

                {section('净冲量 Net Impulse', '（总力 − 体重）积分')}
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(120px,1fr))', gap: 6 }}>
                  <MCardSm cn="0–100ms 净冲量" label="Net Imp 0–100ms" value={m.nimp100} unit="N·s"
                    info={{ formula: '∫₀¹⁰⁰ (F − BW) dt\ntrapezoidal integration', cite: 'Haff & Triplett 2015; VALD V2.0' }} />
                  <MCardSm cn="0–200ms 净冲量" label="Net Imp 0–200ms" value={m.nimp200} unit="N·s"
                    info={{ formula: '∫₀²⁰⁰ (F − BW) dt\ntrapezoidal integration', cite: 'Haff & Triplett 2015; VALD V2.0' }} />
                  <MCardSm cn="0–300ms 净冲量" label="Net Imp 0–300ms" value={m.nimp300} unit="N·s" accent="var(--pos)"
                    info={{ formula: '∫₀³⁰⁰ (F − BW) dt\ntrapezoidal integration', cite: 'Haff & Triplett 2015; VALD V2.0' }} />
                </div>

                {section('双侧不对称 Asymmetry')}
                <div style={{ display: 'flex', fontSize: 10, color: 'var(--muted)', marginBottom: 2, gap: 6, alignItems: 'center' }}>
                  <span>BSI 公式：(左 − 右) / (左 + 右) × 100%</span>
                  <MetricInfo formula={'ASI = (L − R) / (L + R) × 100%\n+ = 左侧主导  − = 右侧主导\n|ASI| <5% 正常  5–10% 轻度  >10% 显著'} cite={'Buckthorpe et al. 2019; Bishop et al. 2021'} />
                </div>
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(220px,1fr))', gap: 8 }}>
                  {m.asymPeak != null && <AsymBar cn="峰值力不对称" label="Asymmetry @ Peak" value={m.asymPeak} />}
                  {m.asym100  != null && <AsymBar cn="100ms 不对称" label="Asymmetry @ 100ms" value={m.asym100} />}
                  {m.asym200  != null && <AsymBar cn="200ms 不对称" label="Asymmetry @ 200ms" value={m.asym200} />}
                  {m.asym300  != null && <AsymBar cn="300ms 不对称" label="Asymmetry @ 300ms" value={m.asym300} />}
                </div>

              </div>
            </>
          );
        })()}
      </main>
    );
  }

  // ── 11. SESSION PERSISTENCE ───────────────────────────────────────────────

  // FORCE-WS-1b (2026-07-10): computed metrics carry a `formulaTip` (short formula
  // + 来源 when established), surfaced as a hover tip in surfaces we own. 峰值总力 is a
  // direct-read peak (max GRF) → NO tip. Onset for all time-referenced metrics = 体重
  // + 5×静止期SD sustained ≥30ms (5SD 起始法, see computeIMTPMetrics above). Tips
  // AUDITED against the computation. Data-only — no rendering/logic here.
  const IMTP_SUMMARY_METRICS = [
    { key: 'peakForce',       label: '峰值总力',       unit: 'N',    better: 'higher' },
    { key: 'netPeakForce',    label: '峰值净力',       unit: 'N',    better: 'higher', formulaTip: '峰值总力 − 体重' },
    { key: 'relPeakForce',    label: '相对峰力',       unit: 'N/kg', better: 'higher', formulaTip: '峰值总力 ÷ 质量' },
    { key: 'relNetPeakForce', label: '相对净峰力',     unit: 'N/kg', better: 'higher', formulaTip: '峰值净力 ÷ 质量' },
    { key: 'f100',            label: '100ms 时间点力', unit: 'N',    better: 'higher', formulaTip: '起点后 100ms 处力值（5 点均值）\n起点 = 体重 + 5×静止期SD，持续≥30ms' },
    { key: 'f200',            label: '200ms 时间点力', unit: 'N',    better: 'higher', formulaTip: '起点后 200ms 处力值（5 点均值）\n起点 = 体重 + 5×静止期SD，持续≥30ms' },
    { key: 'timeToPeak',      label: '达峰时间',       unit: 'ms',   better: 'lower',  formulaTip: '达峰时刻 − 起点时刻\n起点 = 体重 + 5×静止期SD 起始法' },
    { key: 'peakRFD',         label: '峰值 RFD',       unit: 'N/s',  better: 'higher', formulaTip: '10ms 滚动窗 dF/dt 的最大值\n来源：Haff et al. 2015' },
    { key: 'avgRFDtoPeak',    label: 'Avg RFD to Peak',unit: 'N/s',  better: 'higher', formulaTip: '峰值净力 ÷ 达峰时间（整体斜率）\n来源：Haff et al. 2015' },
    { key: 'rfd100',          label: 'RFD 0–100ms',   unit: 'N/s',  better: 'higher', formulaTip: '(F@100ms − F@起点) ÷ 0.1s，自起点计\n来源：Haff et al. 2015' },
    { key: 'nimp100',         label: 'Net Imp 100ms', unit: 'N·s',  better: 'higher', formulaTip: '∫(F − 体重)dt，起点→100ms（梯形积分）' },
    { key: 'nimp200',         label: 'Net Imp 200ms', unit: 'N·s',  better: 'higher', formulaTip: '∫(F − 体重)dt，起点→200ms（梯形积分）' },
    { key: 'nimp300',         label: 'Net Imp 300ms', unit: 'N·s',  better: 'higher', formulaTip: '∫(F − 体重)dt，起点→300ms（梯形积分）' },
    { key: 'asymPeak',        label: '峰值不对称',    unit: '%',    better: null,     formulaTip: '(左 − 右) ÷ (左 + 右) × 100%，峰值处\n来源：Bishop et al. 2018' },
  ];

  function buildIMTPSession(result, fileName, representativeIdx = 0, repUserChanged = false, overrideDate = null) {
    const id   = Date.now() + '_' + Math.random().toString(36).slice(2, 6);
    const date = overrideDate || (result.meta.date
      ? (() => { try { return new Date(result.meta.date).toISOString().slice(0, 10); } catch { return new Date().toISOString().slice(0, 10); } })()
      : 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→end contraction
    // force-time samples the IMTP panel already rendered at import (same total/bw_n as
    // IMTPChart), downsampled to 200 pts, mirroring CMJ's trial.curve so the shared analysis-
    // face plotter works. default = absent (old sessions → honest empty state).
    // migration = none (additive). rollback = drop the field. version = IMTP session builder v2
    // (FORCE-WS-4). Metrics/detection UNCHANGED — values come from t.metrics (computed in
    // detectAllIMTPTrials); this only stops discarding the already-computed curve samples.
    const bw_n = result.meta.weight * G;
    const CURVE_PTS = 200;
    const time = result.time, total = result.total;
    const trials = result.trials.map(t => {
      let curve = null;
      const ph = t.phases;
      if (time && total && ph && ph.onset != null && ph.end != null && ph.end > ph.onset) {
        const segStart = ph.onset, segEnd = ph.end, 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: t.index, metrics: { ...t.metrics }, curve };
    });

    const best = {}, mean = {};
    const metricDirections = new Map(IMTP_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 => typeof v === 'number' && Number.isFinite(v));
      if (!vals.length) return;
      if (better === 'higher')      best[key] = Math.max(...vals);
      else if (better === 'lower')  best[key] = Math.min(...vals);
      else                          best[key] = vals[0];
      mean[key] = +(vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(2);
    });

    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: fileName || '', trialCount: trials.length, bodyMass: result.meta.weight, trials,
      best: representative.metrics,
      autoBest: best,
      mean,
      representative,
      profileSource: { type: 'imtp', trialIndex: representative.index, mode: repUserChanged ? 'manual' : 'auto' },
      provenance: { source: 'force_plate_upload', testType: 'imtp', fileName, savedAt: new Date().toISOString(), ...(result.meta._intake ? { intake: { ...result.meta._intake } } : {}) },
      // Algorithm version governance (FORCE-SCIENCE M1 §2.6). version 'imtp-2' = per-trial
      // bounded metrics (no cross-trial RFD bleed) + onset-consistent Avg RFD to Peak. Old
      // sessions are never recomputed.
      algorithmRef: (() => {
        const sampling = (typeof window !== 'undefined' && window.ForceCoreSampling)
          ? window.ForceCoreSampling.samplingProfile(result.time, result.meta.frequency) : null;
        const external = !!sampling && sampling.source === 'external-precomputed';
        return {
          testType: 'imtp',
          version: external
            ? 'external-precomputed'
            : sampling?.status === 'non-uniform' ? 'imtp-2-nonuniform-median' : 'imtp-2',
          onsetPolicy: external ? 'external-unknown' : 'threshold-bwn+5sd-30ms-sustain',
          filterPolicy: sampling ? sampling.filterApplied : 'unknown',
          sampling,
          generatedAt: new Date().toISOString(),
        };
      })(),
      protocol: {
        bodyMass: result.meta.weight,
        // effective (measured) rate; header declared kept separately (GPT audit P2.4).
        sampleRate: ((typeof window !== 'undefined' && window.ForceCoreSampling) ? window.ForceCoreSampling.samplingProfile(result.time, result.meta.frequency).effectiveHz : null) ?? null,
        declaredSampleRate: result.meta.frequency,
        onsetRule: 'BW + 5x quiet SD sustained 30ms',
      },
    };
  }

  // ── 12. LONGITUDINAL VIEW ─────────────────────────────────────────────────

  function IMTPLongitudinalView({ 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 { useState: useS2 } = React;
    const [selectedKey, setSelectedKey2] = useS2('peakForce');

    if (!athlete) return <div style={{ padding: 24, color: 'var(--muted)' }}>未找到运动员。</div>;

    const forceSource = window.ForceSessionSource;
    const _allSorted = sessions.map(s =>
      forceSource && typeof forceSource.resolveEffectiveSession === 'function'
        ? forceSource.resolveEffectiveSession(s) : s
    ).sort((a, b) => a.date.localeCompare(b.date));
    const excludedCount = _allSorted.filter(s => !_cmp(s)).length;
    const sorted = _allSorted.filter(_cmp);
    const comparableMetrics = IMTP_SUMMARY_METRICS.filter(m => m.better !== null);
    const metricDef = comparableMetrics.find(m => m.key === selectedKey) || comparableMetrics[0];
    const points = sorted.map(s => ({ date: s.date, value: s.best[metricDef.key] })).filter(p => p.value != null);

    const W = 680, H = 200, ML = 56, MR = 12, MT = 12, MB = 34;
    const PW = W - ML - MR, PH = H - MT - MB;

    const vals = points.map(p => p.value);
    const vMin = vals.length ? Math.min(...vals) : 0;
    const vMax = vals.length ? Math.max(...vals) : 1;
    const pad  = (vMax - vMin) * 0.15 || vMax * 0.1 || 10;
    const yLo  = vMin - pad, yHi = vMax + pad;

    const xS = (i) => ML + (points.length < 2 ? PW / 2 : i / (points.length - 1) * PW);
    const yS = (v)  => MT + PH * (1 - (v - yLo) / (yHi - yLo));

    // P3-C: MDC band
    const IMTP_ICC_MAP = { peakForce: 'imtp_peakForce', rfd100: 'imtp_f100', nimp200: 'imtp_f200' };
    const imtpMdcBand = (() => {
      if (points.length < 2) return null;
      const D = window.DASHBOARD_DATA;
      const iccKey = IMTP_ICC_MAP[selectedKey];
      if (!iccKey) return null;
      const icc = (D.ICC_USER?.[iccKey] ?? D.ICC_DEFAULTS?.[iccKey]);
      if (icc == null) return null;
      const allVals = points.map(p => p.value);
      const m = allVals.reduce((a, b) => a + b, 0) / allVals.length;
      const sd = Math.sqrt(allVals.reduce((s, v) => s + (v - m) ** 2, 0) / allVals.length);
      const mdc = D.computeMDC(sd, icc);
      if (!mdc) return null;
      const latestVal = points[points.length - 1].value;
      return { yTop: yS(latestVal + mdc / 2), yBot: yS(latestVal - mdc / 2), mdc };
    })();

    const yTicks = [];
    const rawStep = (yHi - yLo) / 4;
    const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
    const step = Math.ceil(rawStep / mag) * mag || 1;
    for (let v = Math.ceil(yLo / step) * step; v <= yHi; v += step) yTicks.push(v);

    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: 12 }}>
          <button onClick={onBack} style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '5px 12px', fontSize: 12, cursor: 'pointer', color: 'var(--text-2)', fontFamily: 'var(--font-sans)' }}>← 返回</button>
          <div>
            <div style={{ fontSize: 16, fontWeight: 700 }}>{athlete.name} · IMTP 纵向趋势</div>
            {excludedCount > 0 && <div style={{ fontSize: 11, color: 'var(--danger, #b45309)', marginTop: 4 }}>⚠ {excludedCount} 个受限会话（采样率不足/时间轴异常）已排除出趋势比较</div>}
            <div style={{ fontSize: 11, color: 'var(--muted)' }}>{sorted.length} sessions</div>
          </div>
        </div>

        {/* Metric selector */}
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {comparableMetrics.map(m => (
            <button key={m.key} onClick={() => setSelectedKey2(m.key)} style={{
              fontSize: 11, padding: '4px 10px', borderRadius: 6, cursor: 'pointer',
              background: selectedKey === m.key ? 'var(--accent-soft)' : 'var(--panel-2)',
              border: `1px solid ${selectedKey === m.key ? 'rgba(59,130,246,.4)' : 'var(--border)'}`,
              color: selectedKey === m.key ? 'var(--accent-2)' : 'var(--text-2)',
              fontFamily: 'var(--font-sans)',
            }}>{m.label}</button>
          ))}
        </div>

        {/* SVG trend chart */}
        {points.length >= 2 ? (
          <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '10px 6px 6px' }}>
            <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
              <rect width={W} height={H} fill="var(--panel)" rx="6" />
              {yTicks.map(v => (
                <g key={v}>
                  <line x1={ML} y1={yS(v).toFixed(1)} x2={ML + PW} y2={yS(v).toFixed(1)} stroke="var(--chart-grid)" strokeWidth="1" />
                  <text x={ML - 5} y={yS(v) + 4} textAnchor="end" fontSize="9" fill="var(--muted)" fontFamily="var(--font-mono)">{v >= 1000 ? (v/1000).toFixed(1)+'k' : v}</text>
                </g>
              ))}
              <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="var(--chart-axis)" strokeWidth="1" />
              <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="var(--chart-axis)" strokeWidth="1" />
              {/* MDC band (P3-C) */}
              {imtpMdcBand && (
                <g>
                  <rect x={ML} y={imtpMdcBand.yTop} width={PW} height={imtpMdcBand.yBot - imtpMdcBand.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={imtpMdcBand.yTop - 2} textAnchor="end" fontSize={8.5} fill="var(--muted-2)">MDC±{imtpMdcBand.mdc >= 100 ? imtpMdcBand.mdc.toFixed(0) : imtpMdcBand.mdc.toFixed(1)}</text>
                </g>
              )}
              <polyline
                points={points.map((p, i) => `${xS(i).toFixed(1)},${yS(p.value).toFixed(1)}`).join(' ')}
                fill="none" stroke="var(--accent)" strokeWidth="1.8" />
              {points.map((p, i) => {
                const cx = xS(i), cy = yS(p.value);
                const label = p.value >= 1000 ? (p.value/1000).toFixed(1)+'k' : p.value;
                return (
                  <g key={i}>
                    <circle cx={cx.toFixed(1)} cy={cy.toFixed(1)} r="3.5" fill="var(--accent)" />
                    <text x={cx.toFixed(1)} y={(cy - 7).toFixed(1)} textAnchor="middle" fontSize="9" fill="var(--text-2)" fontFamily="var(--font-mono)">{label}</text>
                    <text x={cx.toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="8" fill="var(--muted)" fontFamily="var(--font-mono)">{p.date.slice(5)}</text>
                  </g>
                );
              })}
            </svg>
          </div>
        ) : (
          <div style={{ padding: '20px 0', color: 'var(--muted)', fontSize: 12 }}>至少需要 2 个 session 才能显示趋势图。</div>
        )}

        {/* Session table */}
        <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
            <thead>
              <tr style={{ background: 'var(--panel-2)' }}>
                {['日期', '文件', '峰值力 (N)', '相对峰力 (N/kg)', 'Peak RFD (N/s)', '试次'].map(h => (
                  <th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 600, color: 'var(--muted)', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.04em', borderBottom: '1px solid var(--border)' }}>{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {[...sorted].reverse().map((s, i) => (
                <tr key={s.id} style={{ borderTop: i > 0 ? '1px solid var(--border)' : 'none', background: s.id === highlightSessionId ? 'var(--accent-soft)' : 'transparent', outline: s.id === highlightSessionId ? '1px solid rgba(59,130,246,.3)' : 'none' }}>
                  <td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{s.date}</td>
                  <td style={{ padding: '7px 12px', color: 'var(--muted)', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.fileName || '—'}</td>
                  <td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--pos)', fontWeight: 600 }}>{s.best.peakForce ?? '—'}</td>
                  <td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)' }}>{s.best.relPeakForce ?? '—'}</td>
                  <td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)' }}>{s.best.peakRFD ?? '—'}</td>
                  <td style={{ padding: '7px 12px', color: 'var(--muted)' }}>{s.trialCount}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </main>
    );
  }

  window.IMTPPanel = IMTPPanel;
  window.IMTPLongitudinalView = IMTPLongitudinalView;
  window.buildIMTPSession = buildIMTPSession;
  window.IMTP_SUMMARY_METRICS = IMTP_SUMMARY_METRICS;
  window.__FORCE_TEST_INTERNALS__ = window.__FORCE_TEST_INTERNALS__ || {};
  window.__FORCE_TEST_INTERNALS__.imtp = {
    detectAllIMTPTrials,
    computeIMTPMetrics,
    filtfilt,
  };
})();
