// force-report.jsx
// Unified 测力台报告 workbench (FORCE-2 skeleton + 综合/CMJ tabs; FORCE-3 adds
// the SJ + IMTP metric-table tabs — curves audited & skipped, see MODULES).
// Direction: docs/decisions/2026-07-09-force-report-unification.md (§0 rulings,
// §2 design, §4 rulings). Mirrors the individual report RP-R2' three-pane
// workbench (模块库 / 画布 / 检查器) but consumes ONLY the unified force data
// foundation (FORCE-1): window.ForceSessionSource + window.DerivedForceMetricRegistry.
//
// ── RED LINES ────────────────────────────────────────────────────────────────
//  · No composite scores, no availability/injury-likelihood inference, no
//    coaching prescriptions, no generated-diagnosis language. Derived cards
//    ALWAYS render their formula + source dates (never hidden) — cross-day
//    pairings are marked, never faked.
//  · Pit #14: the CMJ four-chart rendering is untouchable. This file REUSES the
//    window-exposed cmj.jsx components (CMJChart / CMJNormChart / LoopChart) by
//    calling them EXACTLY as CMJReportPrintBody (report.jsx) does — the trial
//    reconstruction + multi-overlay structure below is a verbatim mirror of that
//    code path. Zero modification to cmj.jsx.
//
// ── SCOPE (this slice) ───────────────────────────────────────────────────────
//  综合 tab: derived-metrics (one card per DerivedForceMetricRegistry entry) +
//  type-overview (per-type latest session + headline raw values).
//  CMJ tab: cmj-curves (F-t curve + F-D / F-V loops) + cmj-metrics (summary
//  table over the selected session) + cmj-norm (normalized force-time overlay).
//  SJ tab (FORCE-3): sj-metrics (SJ_SUMMARY_METRICS table over the selected
//  session). IMTP tab (FORCE-3): imtp-metrics (IMTP_SUMMARY_METRICS table).
//  SJ/IMTP curves are DELIBERATELY ABSENT — their sessions store only
//  {index, metrics} trials (no force-time curves) and sj.jsx/imtp.jsx expose no
//  chart component, so no curve is cleanly reachable (see MODULES comment).
//
// FORCE-4 (2026-07-10): the workbench is now the REAL report-surface entry (the
//  retired CMJReportModal's link-outs open it at the CMJ tab, session pre-selected
//  via initialTab/initialSelection). Export offers 当前 tab / 全部 tab (multi-node
//  doPrintBundle, one page per active tab). ForceReportPrintBody gains an optional
//  bodyId for the all-tab staging. See docs …force-report-unification.md §3.
//
// FORCE-5a (2026-07-10): every *-metrics module gains (1) a metric multi-select
//  (inspector chips over that type's live SUMMARY_METRICS defs, default ALL) and
//  (2) a four-way presentation switcher 表格/柱状/趋势/柱状+趋势. Charts are ONE
//  parameterized SVG renderer (ForceMetricSeriesChart: bars on/off × line on/off)
//  over ForceSessionSource.listSessions + getMetricValue series — raw values on
//  each metric's OWN scale (MB-3 ruling: no cross-metric rescaling), chronological
//  ascending, malformed dates filtered (pit #15). State is session-scoped per
//  workbench instance (like session selection — NOT persisted to localStorage this
//  slice). The canvas body doubles as the print clone source, so 导出·当前tab and
//  the workbench all-tab staging carry the live mode; the report-page bundle
//  staging (app-modals.jsx, protected) passes no metricSel/vizMode props and thus
//  renders defaults (all metrics · 表格) — kept, per the FORCE-4 staging design.
//
// FORCE-5d (2026-07-10): the CMJ tab gains one 曲线对比 module (curve-compare,
//  defaultOn:false). It overlays force-time (GRF/BW) curves of the selected items'
//  representative trials, driven by a 对比模式 toggle that reuses the EXISTING 5b
//  session selection (compareSel · 会话对比) or 5c comparison-athlete selection
//  (athleteCompareSel · 运动员对比) — NO new selection system, only a curveMode flag.
//  Fork audit (see docs …§3.2 FORCE-5d): ForceCompareView's F-t overlay is an INLINE
//  IIFE (cmj.jsx ~4409, reading internal picks→entries), NOT a separable/window-
//  reachable component; embedding the whole ForceCompareView (option A) would bleed its
//  interactive chrome into ForceReportPrintBody's static clone AND carry its own
//  selection (a 3rd system). So the F-t overlay is rendered here (ForceCurveOverlay) as
//  a VERBATIM mirror of that overlay — pure presentation of ALREADY-STORED, ALREADY-BW-
//  normalized curve samples (trial.curve.t/.f), NO reconstruction/curve-math — the same
//  mirror idiom buildForceCmjCurveModel already uses (pit #14). cmj.jsx stays byte-
//  untouched (no exposure line needed). SJ/IMTP have no curve-compare — their sessions
//  store no force-time curve (FORCE-3), matching cmj-curves/cmj-norm being CMJ-only.
//
// Window exposure: ForceReportWorkbench (component) + ForceReportModules (registry).

// ── One-time scoped CSS injection (workbench chrome only; paper reuses the
//    global .rpt / .paper classes). Cold tokens; minimal per direction §2.3. ──
(function () {
  if (typeof document === 'undefined') return;
  if (document.getElementById('force-report-style')) return;
  const css = `
  #force-report-root {
    width: min(1240px, 94vw); height: min(88vh, 900px);
    position: relative; display: flex; flex-direction: column;
    background: var(--panel); border: 1px solid var(--border);
    border-radius: 14px; overflow: hidden;
    box-shadow: 0 24px 60px rgba(5,8,12,.4);
    color: var(--text, var(--ink));
  }
  #force-report-root .frp-head {
    display: flex; align-items: center; gap: 14px;
    padding: 14px 20px; border-bottom: 1px solid var(--border);
    background: var(--panel-2);
  }
  #force-report-root .frp-head .frp-avatar {
    width: 40px; height: 40px; border-radius: 50%;
    background: linear-gradient(135deg, #2563eb, #1e40af);
    color: #fff; display: grid; place-items: center; font-weight: 600; font-size: 14px;
    flex-shrink: 0;
  }
  #force-report-root .frp-id-name { font-size: 15px; font-weight: 650; color: var(--ink); }
  #force-report-root .frp-id-role {
    font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
    letter-spacing: .06em; margin-top: 2px;
  }
  #force-report-root .frp-head .frp-x {
    margin-left: auto; border: 1px solid var(--border); background: transparent;
    color: var(--muted); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px;
  }
  #force-report-root .frp-tabs {
    display: flex; gap: 4px; padding: 8px 20px 0; border-bottom: 1px solid var(--border);
    background: var(--panel-2);
  }
  #force-report-root .frp-tab {
    border: 1px solid transparent; border-bottom: none; background: transparent;
    color: var(--muted); padding: 8px 16px; border-radius: 8px 8px 0 0; cursor: pointer;
    font-size: 13px; font-weight: 600;
  }
  #force-report-root .frp-tab.on {
    background: var(--panel); color: var(--ink);
    border-color: var(--border); margin-bottom: -1px;
  }
  #force-report-root .frp-grid {
    flex: 1; min-height: 0; display: grid;
    grid-template-columns: 220px 1fr 300px; gap: 0;
  }
  #force-report-root .frp-pane { min-height: 0; overflow-y: auto; }
  #force-report-root .frp-library { border-right: 1px solid var(--border); padding: 14px; background: var(--panel-2); }
  #force-report-root .frp-canvas { padding: 22px; background: var(--panel-2); display: flex; justify-content: center; }
  #force-report-root .frp-inspector { border-left: 1px solid var(--border); padding: 14px; }
  #force-report-root .frp-pane-title {
    font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
    color: var(--muted); margin-bottom: 10px;
  }
  #force-report-root .frp-lib-i {
    display: flex; align-items: center; gap: 8px; width: 100%;
    border: 1px solid var(--border); background: var(--panel); color: var(--ink);
    border-radius: 8px; padding: 9px 11px; margin-bottom: 6px; cursor: pointer; text-align: left; font: inherit;
  }
  #force-report-root .frp-lib-i.sel { border-color: var(--accent); }
  #force-report-root .frp-lib-i .frp-lib-nm { flex: 1; font-size: 12.5px; }
  #force-report-root .frp-lib-i .frp-lib-state { font-size: 11px; color: var(--muted); }
  #force-report-root .frp-lib-i.on .frp-lib-state { color: var(--accent); }
  #force-report-root .frp-lib-i.absent { opacity: .5; cursor: not-allowed; }
  #force-report-root .frp-order-list { display: flex; flex-direction: column; gap: 5px; }
  #force-report-root .frp-order-row {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px;
    border: 1px solid var(--border); border-radius: 7px; background: var(--panel-2); padding: 6px 7px 6px 9px;
  }
  #force-report-root .frp-order-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; }
  #force-report-root .frp-order-actions { display: inline-flex; gap: 4px; }
  #force-report-root .frp-order-btn {
    width: 28px; height: 28px; display: grid; place-items: center; padding: 0;
    border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--ink);
    cursor: pointer; font: 600 13px/1 var(--font-mono);
  }
  #force-report-root .frp-order-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
  #force-report-root .frp-order-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  #force-report-root .frp-order-btn:disabled { opacity: .3; cursor: not-allowed; }
  #force-report-root .frp-insp-block { margin-bottom: 16px; }
  #force-report-root .frp-insp-g {
    font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
    color: var(--muted); margin-bottom: 6px;
  }
  #force-report-root .frp-select-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
  #force-report-root .frp-select-row .frp-k { font-size: 11.5px; color: var(--muted); }
  #force-report-root .frp-select-row select {
    border: 1px solid var(--border); background: var(--panel); color: var(--ink);
    border-radius: 7px; padding: 6px 8px; font: inherit; font-size: 12px;
  }
  #force-report-root .frp-insp-note { font-size: 12px; color: var(--text-2, var(--muted)); line-height: 1.6; }
  #force-report-root .frp-comment-input {
    width: 100%; min-height: 110px; resize: vertical; box-sizing: border-box;
    border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px;
    background: var(--panel); color: var(--ink); font: inherit; font-size: 12.5px; line-height: 1.55;
  }
  #force-report-root .frp-comment-state { margin-top: 5px; font-size: 10.5px; color: var(--muted); }
  #force-report-root .frp-comment-state.error { color: var(--danger, #b42318); }
  /* FORCE-5a · inspector: presentation segmented control + metric multi-select chips */
  #force-report-root .frp-seg { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; }
  #force-report-root .frp-seg button {
    border: 1px solid var(--border); background: var(--panel); color: var(--muted);
    border-radius: 7px; padding: 5px 10px; font: inherit; font-size: 11.5px; cursor: pointer;
  }
  #force-report-root [data-frp-profile-category] { min-height: 40px; transition-property: transform, background-color, border-color, color; transition-duration: .16s; }
  #force-report-root [data-frp-profile-category]:active { transform: scale(.96); }
  #force-report-root .frp-seg button.on { border-color: var(--accent); color: var(--ink); }
  #force-report-root .frp-chip-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
  #force-report-root .frp-chip-count { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
  #force-report-root .frp-chip-act {
    border: none; background: transparent; color: var(--accent); font: inherit;
    font-size: 11px; cursor: pointer; padding: 0;
  }
  #force-report-root .frp-chips { display: flex; flex-wrap: wrap; gap: 5px; }
  #force-report-root .frp-chip {
    border: 1px solid var(--border); background: var(--panel); color: var(--muted);
    border-radius: 999px; padding: 3px 9px; font: inherit; font-size: 11px; cursor: pointer;
  }
  #force-report-root .frp-chip.on { border-color: var(--accent); color: var(--ink); }
  #force-report-root .frp-ref-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
  #force-report-root .frp-ref-actions input {
    min-width: 0; width: 100%; border: 1px solid var(--border); border-radius: 7px;
    background: var(--panel); color: var(--ink); padding: 6px 8px; font: 11px var(--font-mono);
  }
  #force-report-root .frp-branding-preview { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
  #force-report-root .frp-branding-preview img { max-width: 92px; height: 30px; object-fit: contain; }
  #force-report-root .frp-branding-upload { position: relative; overflow: hidden; cursor: pointer; }
  #force-report-root .frp-branding-upload input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
  #force-report-root .frp-template-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
  #force-report-root .frp-template-item { display: inline-flex; align-items: center; overflow: hidden; border: 1px solid var(--border); border-radius: 999px; background: var(--panel); }
  #force-report-root .frp-template-item.on { border-color: var(--accent); }
  #force-report-root .frp-template-apply, #force-report-root .frp-template-delete { border: 0; background: transparent; color: var(--muted); cursor: pointer; font: 11px var(--font-sans); }
  #force-report-root .frp-template-apply { max-width: 154px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 3px 4px 9px; }
  #force-report-root .frp-template-item.on .frp-template-apply { color: var(--ink); }
  #force-report-root .frp-template-delete { padding: 4px 8px 4px 5px; color: var(--muted-2, var(--muted)); }
  #force-report-root .frp-print-staging { display: none !important; }
  .frp-ref-picker-overlay {
    position: fixed; inset: 0; z-index: 240; display: grid; place-items: center; padding: 24px;
    background: rgba(35,42,52,.42); backdrop-filter: blur(3px);
  }
  .frp-ref-picker {
    width: min(920px, calc(100vw - 48px)); max-height: min(780px, calc(100dvh - 48px));
    display: grid; grid-template-rows: auto auto minmax(0,1fr) auto;
    overflow: hidden; border: 1px solid #d8dde4; border-radius: 14px; background: #fff;
    color: #263142; box-shadow: 0 26px 80px rgba(31,40,53,.24);
  }
  .frp-ref-picker-head, .frp-ref-picker-foot {
    display: flex; align-items: center; gap: 10px; padding: 14px 16px;
  }
  .frp-ref-picker-head { border-bottom: 1px solid #e3e6ea; }
  .frp-ref-picker-head b { font-size: 15px; }
  .frp-ref-picker-head span { color: #758091; font-size: 11px; }
  .frp-ref-picker-head button { margin-left: auto; }
  .frp-ref-picker-filters {
    display: grid; grid-template-columns: minmax(180px,1.5fr) repeat(4,minmax(105px,1fr));
    gap: 8px; padding: 12px 16px; border-bottom: 1px solid #e3e6ea; background: #f7f8fa;
  }
  .frp-ref-picker-filters label, .frp-ref-age-grid label {
    display: grid; gap: 4px; color: #6f7a8a; font-size: 10px;
  }
  .frp-ref-picker input, .frp-ref-picker select {
    min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid #d7dce3;
    border-radius: 7px; background: #fff; color: #263142; padding: 7px 8px; font: inherit; font-size: 11.5px;
  }
  .frp-ref-picker-body { min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) 250px; }
  .frp-ref-picker-list { min-height: 0; overflow: auto; padding: 0 16px 14px; border-right: 1px solid #e3e6ea; }
  .frp-ref-picker-tools {
    position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px;
    padding: 12px 0 9px; background: #fff; color: #707b8a; font-size: 10.5px;
  }
  .frp-ref-picker-tools button, .frp-ref-picker-head button, .frp-ref-picker-foot button {
    border: 1px solid #d7dce3; border-radius: 7px; background: #fff; color: #3e4a5b;
    padding: 6px 9px; cursor: pointer; font: inherit; font-size: 11px;
  }
  .frp-ref-picker-tools button:first-of-type { margin-left: auto; }
  .frp-ref-athlete-row {
    display: grid; grid-template-columns: 18px minmax(130px,1fr) repeat(4,minmax(70px,.65fr));
    align-items: center; gap: 8px; padding: 8px 4px; border-top: 1px solid #eceef1; font-size: 11px;
  }
  .frp-ref-athlete-row input { width: auto; }
  .frp-ref-athlete-row b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; }
  .frp-ref-athlete-row span { overflow: hidden; color: #737e8e; text-overflow: ellipsis; white-space: nowrap; }
  .frp-ref-picker-summary { min-height: 0; overflow: auto; padding: 14px; background: #fafbfc; }
  .frp-ref-picker-summary h4 { margin: 0 0 4px; font-size: 12px; }
  .frp-ref-picker-summary p { margin: 0 0 10px; color: #758091; font-size: 10.5px; line-height: 1.5; }
  .frp-ref-cohort-sources { display: grid; gap: 6px; margin-bottom: 14px; }
  .frp-ref-cohort-source {
    width: 100%; display: grid; gap: 3px; padding: 8px 9px; text-align: left;
    border: 1px solid #d7dce3; border-radius: 8px; background: #fff; color: #354154;
    cursor: pointer; font: inherit;
  }
  .frp-ref-cohort-source:hover { border-color: #9ca7b7; background: #f5f7fa; }
  .frp-ref-cohort-source b { font-size: 10.5px; }
  .frp-ref-cohort-source span { color: #758091; font-size: 9.5px; line-height: 1.4; }
  .frp-ref-age-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 6px; margin-bottom: 12px; }
  .frp-ref-selected { display: grid; gap: 5px; }
  .frp-ref-selected div {
    display: flex; align-items: center; justify-content: space-between; gap: 8px;
    padding: 6px 7px; border: 1px solid #e0e4e9; border-radius: 7px; background: #fff; font-size: 10.5px;
  }
  .frp-ref-selected button { border: 0; background: transparent; color: #7b8492; cursor: pointer; }
  .frp-ref-picker-empty { padding: 24px 8px; color: #758091; text-align: center; font-size: 11px; }
  .frp-ref-picker-foot { justify-content: flex-end; border-top: 1px solid #e3e6ea; }
  .frp-ref-picker-foot .primary { border-color: #303b4d; background: #303b4d; color: #fff; font-weight: 650; }
  @media (max-width: 820px) {
    .frp-ref-picker-filters { grid-template-columns: 1fr 1fr; }
    .frp-ref-picker-body { grid-template-columns: 1fr; }
    .frp-ref-picker-list { border-right: 0; border-bottom: 1px solid #e3e6ea; }
    .frp-ref-picker-summary { max-height: 240px; }
    .frp-ref-athlete-row { grid-template-columns: 18px minmax(120px,1fr) 70px 70px; }
    .frp-ref-athlete-row span:nth-last-child(-n+2) { display: none; }
  }
  #force-report-root .frp-trial-chip {
    display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border);
    border-radius: 7px; padding: 4px 8px; background: var(--panel); color: var(--muted);
    font-family: var(--font-mono); font-size: 10.5px; cursor: pointer;
  }
  #force-report-root .frp-trial-chip input { margin: 0; accent-color: var(--accent); }
  #force-report-root .frp-trial-chip.on { border-color: var(--accent); color: var(--ink); background: var(--accent-soft); }
  #force-report-root .frp-trial-chip.unavailable { opacity: .5; cursor: not-allowed; }
  #force-report-root .frp-foot {
    display: flex; align-items: center; gap: 10px;
    padding: 12px 20px; border-top: 1px solid var(--border); background: var(--panel-2);
  }
  #force-report-root .frp-foot .frp-grow { flex: 1; }
  #force-report-root .frp-foot .frp-pri {
    border: 0; background: var(--accent); color: #fff; border-radius: 8px;
    padding: 8px 18px; cursor: pointer; font-size: 13px; font-weight: 600;
  }
  #force-report-root .frp-foot .frp-ghost {
    border: 1px solid var(--border); background: transparent; color: var(--muted);
    border-radius: 8px; padding: 8px 16px; cursor: pointer; font-size: 13px;
  }
  /* Derived-metric card (paper) — cold tokens, formula + source dates always shown */
  .rpt .frp-derived-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 10px 0 6px; }
  .rpt .frp-dcard { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
  .rpt .frp-dcard .frp-dlabel { font-size: 13px; font-weight: 650; color: var(--ink); }
  .rpt .frp-dcard .frp-dval { font-family: var(--font-mono); font-size: 26px; font-weight: 600; color: var(--ink); margin: 6px 0 2px; }
  .rpt .frp-dcard .frp-dval.absent { font-size: 15px; color: var(--muted); }
  .rpt .frp-dcard .frp-dformula {
    font-family: var(--font-mono); font-size: 11px; color: var(--muted);
    background: #f6f7f9; border-radius: 6px; padding: 6px 8px; margin: 8px 0;
  }
  .rpt .frp-dsource { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted-2, var(--muted)); line-height: 1.7; }
  .rpt .frp-crossday {
    display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 999px;
    font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .05em;
    background: rgba(251,146,60,.14); color: #b45309; border: 1px solid rgba(251,146,60,.4);
  }
  .rpt .frp-overview-block { border: 1px solid var(--line); border-radius: 10px; padding: 12px 16px; margin: 10px 0; }
  .rpt .frp-overview-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
  .rpt .frp-overview-head .frp-ot { font-size: 13px; font-weight: 650; color: var(--ink); }
  .rpt .frp-overview-head .frp-od { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }
  .rpt .frp-overview-row { display: flex; justify-content: space-between; font-size: 12.5px; padding: 4px 0; border-top: 1px solid var(--line); }
  .rpt .frp-overview-row .frp-orv { font-family: var(--font-mono); color: var(--ink); }
  .rpt .frp-metric-grp { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted-2, var(--muted)); margin: 12px 0 4px; }
  .rpt .frp-metric-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; font-size: 12.5px; padding: 5px 0; border-bottom: 1px solid var(--line); }
  .rpt .frp-metric-row.has-reference { grid-template-columns: minmax(150px,1fr) 86px 94px 76px; }
  .rpt .frp-metric-row.has-reference.show-percentile { grid-template-columns: minmax(140px,1fr) 78px 88px 68px 66px; }
  .rpt .frp-metric-row .frp-metric-number {
    color: var(--ink); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500;
    line-height: 1.45; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap;
  }
  .rpt .frp-ref-head { color: var(--muted); font: 600 9px var(--font-mono); text-align: right; }
  .rpt .frp-ref-missing { color: var(--muted); }
  .rpt .frp-chart-wrap { background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 6px 8px; margin: 10px 0; }
  .rpt .frp-absent { font-size: 12.5px; color: var(--muted); font-style: italic; padding: 10px 0; }
  .rpt .frp-report-comment { margin-top: 14px; }
  .rpt .frp-report-comment-body {
    border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
    font-size: 12.5px; line-height: 1.7; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
  }
  /* FORCE-5a · per-metric mini-chart grid (paper: 柱状 / 趋势 / 柱状+趋势) */
  .rpt .frp-viz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin: 10px 0 6px; }
  .rpt .frp-viz-cell { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff; }
  .rpt .frp-viz-lbl { font-size: 12px; font-weight: 650; color: var(--ink); }
  .rpt .frp-viz-range { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 4px; }
  .rpt .frp-profile-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 10px; margin: 10px 0 6px; }
  .rpt .frp-profile-group { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff; break-inside: avoid; }
  .rpt .frp-profile-unit { margin-bottom: 8px; color: var(--muted); font: 600 10px var(--font-mono); }
  .rpt .frp-profile-row { display: grid; grid-template-columns: minmax(92px,.9fr) minmax(90px,1.4fr) 72px; align-items: center; gap: 8px; padding: 4px 0; }
  .rpt .frp-profile-row.has-reference { grid-template-columns: minmax(88px,.9fr) minmax(76px,1.2fr) 66px 92px; }
  .rpt .frp-profile-row.has-reference.show-percentile { grid-template-columns: minmax(82px,.85fr) minmax(68px,1fr) 62px 82px 62px; }
  .rpt .frp-profile-label { overflow: hidden; color: var(--ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
  .rpt .frp-profile-track { position: relative; height: 8px; overflow: visible; border-radius: 999px; background: #eef0f4; }
  .rpt .frp-profile-track i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: #6675ad; }
  .rpt .frp-profile-track b { position: absolute; top: -3px; width: 2px; height: 14px; background: #d97706; transform: translateX(-1px); }
  .rpt .frp-profile-value { color: var(--ink); text-align: right; font: 650 10.5px var(--font-mono); font-variant-numeric: tabular-nums; }
  .rpt .frp-profile-reference, .rpt .frp-percentile { color: var(--muted); text-align: right; font: 9.5px var(--font-mono); font-variant-numeric: tabular-nums; }
  .rpt .frp-reference-population { margin: -2px 0 8px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); font-size: 9.5px; line-height: 1.5; }
  .rpt .frp-keypoint-legend { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 12px; margin-top: 7px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; }
  .rpt .frp-keypoint-legend span { color: var(--ink-2); font-size: 9.5px; line-height: 1.45; }
  .rpt .frp-keypoint-legend b { color: var(--ink); font-family: var(--font-mono); }
  .rpt .frp-classification-card { display: grid; grid-template-columns: auto 1fr; gap: 7px 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; }
  .rpt .frp-classification-type { grid-row: 1 / span 2; align-self: center; color: var(--ink); font: 700 22px var(--font-serif); }
  .rpt .frp-classification-card strong { color: var(--ink); font-size: 12px; }
  .rpt .frp-classification-card span { color: var(--muted); font-size: 10px; line-height: 1.5; }
  .rpt .frp-classification-card small { grid-column: 1 / -1; padding-top: 7px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; line-height: 1.5; }
  .rpt .frp-phase-card, .rpt .frp-asym-card { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 10px 0 6px; break-inside: avoid; }
  .rpt .frp-phase-total { color: var(--ink); font: 650 20px var(--font-mono); font-variant-numeric: tabular-nums; }
  .rpt .frp-phase-track { display: flex; height: 12px; overflow: hidden; border-radius: 6px; margin: 9px 0; background: #eef0f4; }
  .rpt .frp-phase-track span:nth-child(1) { background: #7595d2; }.rpt .frp-phase-track span:nth-child(2) { background: #e9ad67; }.rpt .frp-phase-track span:nth-child(3) { background: #68ba97; }
  .rpt .frp-phase-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--muted); font-size: 10.5px; }
  .rpt .frp-asym-row { display: grid; grid-template-columns: minmax(110px,.8fr) minmax(120px,1.5fr) 68px; align-items: center; gap: 10px; padding: 5px 0; }
  .rpt .frp-asym-axis { position: relative; height: 14px; overflow: hidden; border-radius: 5px; background: linear-gradient(90deg,#fff4f4,#f1faf5 50%,#fff4f4); }
  .rpt .frp-asym-axis::after { content: ''; position: absolute; inset: 0 auto 0 50%; width: 1px; background: #9ca3af; }
  .rpt .frp-asym-fill { position: absolute; top: 3px; bottom: 3px; z-index: 1; border-radius: 3px; background: #6675ad; }.rpt .frp-asym-fill.left { right: 50%; }.rpt .frp-asym-fill.right { left: 50%; }
  .rpt .frp-asym-value { text-align: right; color: var(--ink); font: 650 10.5px var(--font-mono); font-variant-numeric: tabular-nums; }
  /* FORCE-5b · 多会话对比表 (指标行 × 会话列 + 相邻 Δ) — cold tokens, raw values */
  .rpt .frp-cmp-wrap { overflow-x: auto; margin: 10px 0 6px; }
  .rpt table.frp-cmp { border-collapse: collapse; width: 100%; font-size: 12px; }
  .rpt table.frp-cmp th, .rpt table.frp-cmp td { border-bottom: 1px solid var(--line); padding: 6px 10px; text-align: right; white-space: nowrap; }
  .rpt table.frp-cmp th.frp-cmp-metric, .rpt table.frp-cmp td.frp-cmp-metric { text-align: left; font-weight: 500; color: var(--ink); }
  .rpt table.frp-cmp thead th { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
  .rpt table.frp-cmp thead th.frp-cmp-delta { color: var(--ink); }
  .rpt table.frp-cmp td.frp-cmp-v { font-family: var(--font-mono); color: var(--ink); }
  .rpt table.frp-cmp td.frp-cmp-miss { font-family: var(--font-mono); color: var(--muted); }
  .rpt table.frp-cmp td.frp-cmp-delta { font-family: var(--font-mono); font-weight: 600; }
  /* FORCE-5c · 个体间对比表 (指标行 × 运动员列) — reuses .frp-cmp; per-column header
     carries 姓名 + #号 + 会话日期 (mandatory). RED LINE: no cross-athlete coloring —
     every value cell is ink-neutral (no better-direction color, no best-value mark). */
  .rpt table.frp-cmp th.frp-cmp-athlete { text-align: right; }
  .rpt table.frp-cmp th.frp-cmp-athlete.cur { color: var(--ink); }
  .rpt table.frp-cmp th .frp-cmp-anm { display: block; color: var(--ink); font-weight: 650; font-family: var(--font-sans, inherit); }
  .rpt table.frp-cmp th .frp-cmp-asub { display: block; font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); font-weight: 400; margin-top: 2px; }
  .rpt .frp-cmp-foot { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 8px; line-height: 1.7; }
  /* FORCE-5d · 曲线对比 legend (identity color + date / 姓名·日期) — cold tokens */
  .rpt .frp-curve-legend { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 2px 8px; }
  .rpt .frp-curve-legend .frp-cl-item { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; }
  .rpt .frp-curve-legend .frp-cl-sw { width: 22px; height: 2.5px; border-radius: 2px; display: inline-block; flex-shrink: 0; }
  .rpt .frp-curve-legend .frp-cl-nm { font-weight: 600; }
  .rpt .frp-curve-legend .frp-cl-meta { font-family: var(--font-mono); color: var(--muted); font-size: 10px; }
  /* FORCE-WS-1b · computed-metric formula hover (mirrors mockup .tip) — cold tokens.
     Screen-only affordance on the 5b/5c compare tables + *-metrics module rows;
     print strips the underline (hover is meaningless on paper). */
  .rpt .frp-tip { border-bottom: 1px dotted var(--muted); cursor: help; position: relative; }
  .rpt .frp-tip:hover::after {
    content: attr(data-tip); position: absolute; left: 0; bottom: calc(100% + 6px); z-index: 30;
    background: var(--ink); color: var(--panel, #fff); font-size: 11px; font-weight: 400;
    padding: 7px 10px; border-radius: 7px; white-space: pre-line; width: max-content; max-width: 300px;
    box-shadow: 0 6px 20px rgba(22,28,40,.25); font-family: var(--font-sans, inherit); line-height: 1.5;
  }
  .rpt .frp-tip:hover::before {
    content: ''; position: absolute; left: 12px; bottom: calc(100% + 1px);
    border: 5px solid transparent; border-top-color: var(--ink); z-index: 30;
  }
  @media print { .rpt .frp-tip { border-bottom: none; cursor: auto; } .rpt .frp-tip:hover::after, .rpt .frp-tip:hover::before { display: none; } }
  `;
  const el = document.createElement('style');
  el.id = 'force-report-style';
  el.textContent = css;
  document.head.appendChild(el);
})();

// ── ForceReportModules registry (IIFE · mirrors the TeamModules federation
//    shape). Pure metadata + a couple of resolution helpers over the FORCE-1
//    session source. Rendering lives in ForceReportPrintBody below (the same
//    split report.jsx uses: registry declares, component renders). THIS REGISTRY
//    IS THE EXTENSION POINT — a new tab/module = a new entry here, no consumer
//    change (同根同源). ──
(function () {
  // Tabs. `type` links a tab to a ForceSessionSource test-type id (null = the
  // cross-type 综合 tab, always present). `always` tabs never gate on data.
  const TABS = Object.freeze([
    Object.freeze({ id: 'overview', label: '综合', type: null, always: true }),
    Object.freeze({ id: 'cmj', label: 'CMJ', type: 'cmj', always: false }),
    Object.freeze({ id: 'sj', label: 'SJ', type: 'sj', always: false }),
    Object.freeze({ id: 'imtp', label: 'IMTP', type: 'imtp', always: false }),
  ]);

  // Modules per tab. `defaultOn` seeds the enabled state.
  //
  // SJ / IMTP CURVES — DELIBERATELY NOT REGISTERED (FORCE-3 audit): unlike CMJ,
  // an SJ/IMTP session's trials are `[{ index, metrics }]` — buildSJSession /
  // buildIMTPSession store NO force-time curve (grep 'curve' in sj.jsx/imtp.jsx
  // is empty), and __FORCE_TEST_INTERNALS__.sj/.imtp expose only detectors +
  // metric fns (NO chart component like cmj's CMJChart/LoopChart). A curve
  // module would require either fabricating curve data (red line) or adding
  // exposure lines to the protected files beyond the one-line budget. So the
  // SJ/IMTP tabs are tables-only this slice — an acceptable outcome per task.
  const MODULES = Object.freeze([
    Object.freeze({ id: 'derived-metrics', tab: 'overview', label: '衍生指标', defaultOn: true,
      note: '每个衍生指标一张卡 · 数值 + 完整公式 + 各来源会话日期（跨日会标注）。' }),
    Object.freeze({ id: 'type-overview', tab: 'overview', label: '类型对照表', defaultOn: true,
      note: '每个有数据的测试类型 · 最新会话日期 + 头部指标原始实测值。' }),
    Object.freeze({ id: 'cmj-curves', tab: 'cmj', label: 'CMJ 力学曲线', defaultOn: true,
      note: '力-时间曲线（代表 trial）+ F-D / F-V 环路 · 复用分析页原生图表组件。' }),
    Object.freeze({ id: 'cmj-metrics', tab: 'cmj', label: 'CMJ 指标表', defaultOn: true,
      note: '所选会话的 summary 指标原始实测值，按板块分组。可在下方多选指标并切换呈现方式（表格/柱状/趋势/柱状+趋势）。' }),
    Object.freeze({ id: 'cmj-norm', tab: 'cmj', label: 'CMJ 归一化力时序', defaultOn: true,
      note: '多 trial 归一化力-时间叠加（onset→takeoff 0–100%）· 复用 CMJNormChart。' }),
    Object.freeze({ id: 'cmj-classification', tab: 'cmj', label: 'CMJ 分型', defaultOn: true,
      note: '代表 trial 保存时的 CMJ 分型、单峰/双峰与 LF1/LF2 结构；不重新分类、不生成训练处方。' }),
    Object.freeze({ id: 'cmj-phase-visual', tab: 'cmj', label: 'CMJ 阶段结构', defaultOn: false,
      note: '代表 trial 的卸载、制动、推进时长与占比 · 只读取已保存指标，不重新检测。' }),
    Object.freeze({ id: 'cmj-asymmetry-visual', tab: 'cmj', label: 'CMJ 左右不对称', defaultOn: false,
      note: '代表 trial 的保存时左右不对称参数 · 正值表示左侧主导；仅作技术证据呈现。' }),
    Object.freeze({ id: 'cmj-metric-profile', tab: 'cmj', label: 'CMJ 指标剖面', defaultOn: false,
      note: '选择一个或多个指标类别，并在各类别内勾选指标；以同单位条带呈现代表 trial 的保存值。RFD 仅为推荐默认。' }),
    Object.freeze({ id: 'sj-metrics', tab: 'sj', label: 'SJ 指标表', defaultOn: true,
      note: '所选 SJ 会话的 SJ_SUMMARY_METRICS 原始实测值。可在下方多选指标并切换呈现方式。' }),
    Object.freeze({ id: 'sj-metric-profile', tab: 'sj', label: 'SJ 指标剖面', defaultOn: false,
      note: '选择一个或多个指标类别，并在各类别内勾选指标；以同单位条带呈现代表 trial 的保存值。RFD 仅为推荐默认。' }),
    Object.freeze({ id: 'imtp-metrics', tab: 'imtp', label: 'IMTP 指标表', defaultOn: true,
      note: '所选 IMTP 会话的 IMTP_SUMMARY_METRICS 原始实测值。可在下方多选指标并切换呈现方式。' }),
    Object.freeze({ id: 'imtp-metric-profile', tab: 'imtp', label: 'IMTP 指标剖面', defaultOn: false,
      note: '选择一个或多个指标类别，并在各类别内勾选指标；以同单位条带呈现代表 trial 的保存值。RFD 仅为推荐默认。' }),
    // FORCE-5b (2026-07-10) · 个体内多会话对比 (type-session-compare). One instance
    // PER type tab (mirrors the *-metrics per-tab registration). defaultOn:false —
    // added manually from the module library. Session multi-select (default latest
    // 3) + metric multi-select (default all); table = 指标行 × 会话列 + 相邻 Δ.
    Object.freeze({ id: 'cmj-session-compare', tab: 'cmj', label: 'CMJ 多会话对比', defaultOn: false,
      note: '同类型多会话对比（≥2）· 指标行 × 会话列（时间升序）+ 相邻 Δ（末次−前次，按指标 better 方向着色）。原始实测值。在下方多选会话（默认最近 3 次，最多 5 次）与指标。' }),
    Object.freeze({ id: 'sj-session-compare', tab: 'sj', label: 'SJ 多会话对比', defaultOn: false,
      note: '同类型多会话对比（≥2）· 指标行 × 会话列（时间升序）+ 相邻 Δ（末次−前次，按指标 better 方向着色）。原始实测值。在下方多选会话（默认最近 3 次，最多 5 次）与指标。' }),
    Object.freeze({ id: 'imtp-session-compare', tab: 'imtp', label: 'IMTP 多会话对比', defaultOn: false,
      note: '同类型多会话对比（≥2）· 指标行 × 会话列（时间升序）+ 相邻 Δ（末次−前次，按指标 better 方向着色）。原始实测值。在下方多选会话（默认最近 3 次，最多 5 次）与指标。' }),
    // FORCE-5c (2026-07-10) · 个体间对比 (athlete-compare). One instance PER type tab
    // (mirrors the *-session-compare per-tab registration). defaultOn:false — added
    // manually from the library. RED LINES: raw values side-by-side ONLY — no ranking
    // score, no weighting, no best-value mark, no cross-athlete coloring. Each column
    // header MANDATORILY carries the source session date. Current athlete column uses
    // the workbench's selected session for the type; each comparison athlete uses their
    // LATEST session of the type (no per-opponent session picker this slice — future
    // refinement). Cap 4 comparison athletes (print width).
    Object.freeze({ id: 'cmj-athlete-compare', tab: 'cmj', label: 'CMJ 个体间对比', defaultOn: false,
      note: '个体间对比 · 指标行 × 运动员列。首列为当前运动员（用工作台所选会话），其后为对比运动员（各自最新会话）。列头强制标注 姓名 · #号 · 会话日期。原始实测值并排，无排名、无加权、无跨运动员着色。在下方选择对比运动员（最多 4 名）与指标。' }),
    Object.freeze({ id: 'sj-athlete-compare', tab: 'sj', label: 'SJ 个体间对比', defaultOn: false,
      note: '个体间对比 · 指标行 × 运动员列。首列为当前运动员（用工作台所选会话），其后为对比运动员（各自最新会话）。列头强制标注 姓名 · #号 · 会话日期。原始实测值并排，无排名、无加权、无跨运动员着色。在下方选择对比运动员（最多 4 名）与指标。' }),
    Object.freeze({ id: 'imtp-athlete-compare', tab: 'imtp', label: 'IMTP 个体间对比', defaultOn: false,
      note: '个体间对比 · 指标行 × 运动员列。首列为当前运动员（用工作台所选会话），其后为对比运动员（各自最新会话）。列头强制标注 姓名 · #号 · 会话日期。原始实测值并排，无排名、无加权、无跨运动员着色。在下方选择对比运动员（最多 4 名）与指标。' }),
    // FORCE-5d (2026-07-10) · 曲线对比 (curve-compare). CMJ tab ONLY — SJ/IMTP sessions
    // store no force-time curve (FORCE-3 finding), so no overlay is possible for those
    // tabs (matching cmj-curves/cmj-norm being CMJ-only). defaultOn:false — manual add.
    // Force-time (GRF/BW) overlay of the selected items' representative trials, driven
    // by a 对比模式 toggle over the EXISTING 5b session selection (compareSel) OR 5c
    // comparison-athlete selection (athleteCompareSel) — NO new selection system. Curves
    // are plotted from each trial's ALREADY-STORED, ALREADY-BW-normalized curve samples
    // (mirror of the analysis-page 测力台-对比 F-t overlay, cmj.jsx ForceCompareView) —
    // presentation only, no curve reconstruction/math. Print = static SVG snapshot.
    Object.freeze({ id: 'curve-compare', tab: 'cmj', label: 'CMJ 曲线对比', defaultOn: false,
      note: '力-时间曲线叠加对比（归一化 GRF/BW，onset→takeoff 0–100%）· 复用分析页「测力台-对比」的曲线叠加内核（读取会话已存曲线，无重建）。在检查器切换 会话对比 / 运动员对比：会话对比叠加当前运动员多会话（复用 5b 选择，最多 5）；运动员对比叠加本人 + 对比运动员各自最新会话（复用 5c 选择，最多 4）。每条曲线一个身份色 + 图例（会话日期 / 运动员姓名 · 日期）。' }),
  ]);

  const TABS_BY_ID = TABS.reduce(function (a, t) { a[t.id] = t; return a; }, {});

  function listTabs() { return TABS.map(function (t) { return { id: t.id, label: t.label, type: t.type, always: t.always }; }); }

  function modulesForTab(tabId) {
    return MODULES.filter(function (m) { return m.tab === tabId; })
      .map(function (m) { return { id: m.id, tab: m.tab, label: m.label, defaultOn: m.defaultOn, note: m.note }; });
  }

  // availableTabs(inputs) → tabs to render. 综合 always; a type tab shows ONLY
  // when the type has ≥1 session AND ≥1 registered module (FORCE-3 gate for
  // SJ/IMTP). Reads ForceSessionSource at call time (load order immaterial).
  function availableTabs(inputs) {
    const FSS = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
    return TABS.filter(function (t) {
      if (t.always) return true;
      if (!modulesForTab(t.id).length) return false;
      if (!FSS || typeof FSS.listSessions !== 'function') return false;
      return FSS.listSessions(inputs, t.type).length > 0;
    }).map(function (t) { return { id: t.id, label: t.label, type: t.type, always: t.always }; });
  }

  // defaultEnabled() → { [moduleId]: bool } seeded from defaultOn.
  function defaultEnabled() {
    const out = {};
    MODULES.forEach(function (m) { out[m.id] = !!m.defaultOn; });
    return out;
  }

  const api = Object.freeze({
    listTabs: listTabs,
    modulesForTab: modulesForTab,
    availableTabs: availableTabs,
    defaultEnabled: defaultEnabled,
    tabById: function (id) { const t = TABS_BY_ID[id]; return t ? { id: t.id, label: t.label, type: t.type, always: t.always } : null; },
  });

  if (typeof window !== 'undefined') window.ForceReportModules = api;
  document.dispatchEvent(new CustomEvent('sports-os:force-report-modules-ready', { detail: api }));
})();

// ── CMJ curve model (pit #14): verbatim mirror of CMJReportPrintBody's trial
//    reconstruction + multi-overlay structure (report.jsx). Reads the live cmj.jsx
//    internals at call time; builds the exact inputs the window-exposed charts
//    consume. NO cmj.jsx edit — pure consumer. ──
function buildForceCmjCurveModel(session) {
  const cmjLive = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
  const reconstructLive = cmjLive.reconstructTrialForLive;
  const repIdx = session && session.representative ? session.representative.index : undefined;
  const trials = (session && session.trials) || [];
  const repTrialObj = trials.find(function (t) { return t.index === repIdx; }) || trials[0];
  const repLive = (reconstructLive && repTrialObj && repTrialObj.curve) ? reconstructLive(repTrialObj) : null;
  const repClass = repTrialObj ? { type: repTrialObj.type, isBimodal: repTrialObj.isBimodal, isLF1: repTrialObj.isLF1 } : null;

  const multi = (function () {
    const src = trials.filter(function (t) { return t.curve && t.curve.t && t.curve.t.length; })
      .map(function (t) { return { curve: t.curve, keyPts: t.keyPts, metrics: t.metrics, index: t.index, label: 'Jump ' + t.index }; });
    if (!src.length || !repLive) return null;
    const bw_n = repLive.bw_n, mass_kg = repLive.mass_kg;
    const sharedTotal = [];
    const jumps = [];
    const localNearest = function (c, target) { let bi = 0, bd = Infinity; for (let i = 0; i < c.t.length; i++) { const d = Math.abs(c.t[i] - target); if (d < bd) { bd = d; bi = i; } } return bi; };
    src.forEach(function (e) {
      const c = e.curve, n = c.t.length, off = sharedTotal.length, kp = e.keyPts || {};
      for (let i = 0; i < n; i++) sharedTotal.push(c.f[i] * bw_n);
      jumps.push({
        index: e.label != null ? e.label.replace(/^Jump\s*/, '') : e.index,
        phases: {
          onset: off,
          minForce:  off + (kp.b != null ? localNearest(c, kp.b) : Math.floor(n * 0.10)),
          minVel:    off + (kp.c != null ? localNearest(c, kp.c) : Math.floor(n * 0.30)),
          zeroCross: off + (kp.e != null ? localNearest(c, kp.e) : Math.floor(n * 0.55)),
          takeoff:   off + (n - 1),
          landing:   null,
        },
        metrics: e.metrics, vel: c.v.slice(), disp: c.d.slice(), quietRef: off,
      });
    });
    return {
      total: sharedTotal, left: sharedTotal.map(function (v) { return v / 2; }), right: sharedTotal.map(function (v) { return v / 2; }),
      bw_n: bw_n, mass_kg: mass_kg, jumps: jumps, compareSelected: new Set(jumps.map(function (_, i) { return i; })), count: jumps.length,
    };
  })();

  return { repLive: repLive, repClass: repClass, multi: multi };
}

// ── FORCE-TRACE M3-B: report CMJ curve model from the FULL stored trace bundle (the same
//    ReadModel truth as the analysis face — no second curve source). repLive = the representative
//    trial's validated chart model (six real columns incl landing); multi concatenates the READY
//    trials' REAL left/right (never total/2) with landing offsets carried through. ──
function frpBuildTraceCmjModel(bundle, session) {
  const trials = (session && session.trials) || [];
  const repIdx = session && session.representative ? session.representative.index : (trials[0] && trials[0].index);
  const repEntry = bundle ? bundle.get(repIdx) : null;
  const repTrialObj = trials.find(function (t) { return t.index === repIdx; }) || trials[0];
  const repClass = repTrialObj ? { type: repTrialObj.type, isBimodal: repTrialObj.isBimodal, isLF1: repTrialObj.isLF1 } : null;
  const repLive = (repEntry && repEntry.state === 'ready') ? repEntry.chartModel : null;
  const ready = trials.map(function (t) { return [t.index, bundle ? bundle.get(t.index) : null]; })
    .filter(function (p) { return p[1] && p[1].state === 'ready'; });
  let multi = null;
  if (ready.length) {
    const sharedTotal = [], sharedLeft = [], sharedRight = [], jumps = [];
    ready.forEach(function (pair) {
      const ti = pair[0], m = pair[1].chartModel;
      const n = m.total.length, off = sharedTotal.length, ph = m.phases;
      for (let i = 0; i < n; i++) { sharedTotal.push(m.total[i]); sharedLeft.push(m.left[i]); sharedRight.push(m.right[i]); }
      jumps.push({
        index: ti,
        phases: { onset: off + ph.onset, minForce: off + ph.minForce, minVel: off + ph.minVel, zeroCross: off + ph.zeroCross, takeoff: off + ph.takeoff, landing: ph.landing != null ? off + ph.landing : null },
        metrics: (trials.find(function (t) { return t.index === ti; }) || {}).metrics,
        vel: Array.from({ length: n }, function (_, i) { return m.velAt(i); }),
        disp: Array.from({ length: n }, function (_, i) { return m.dispAt(i); }),
        quietRef: off,
      });
    });
    const first = ready[0][1].chartModel;
    multi = {
      total: sharedTotal, left: sharedLeft, right: sharedRight,
      bw_n: first.bw_n, mass_kg: first.mass_kg, jumps: jumps,
      compareSelected: new Set(jumps.map(function (_, i) { return i; })), count: jumps.length,
      partial: ready.length < trials.length,
    };
  }
  return { repLive: repLive, repClass: repClass, multi: multi, repEntryState: repEntry ? repEntry.state : 'absent' };
}

// Report trial selection is expressed in stable session trial.index values. Chart components
// consume positions in their filtered multi.jumps array, so project explicitly at render time;
// missing/corrupt trials never become an accidental positional match. Undefined preserves the
// existing non-workbench staging default (all drawable trials).
function frpReportTrialPositions(multi, selectedTrialIndices) {
  if (!multi || !Array.isArray(multi.jumps)) return new Set();
  if (!Array.isArray(selectedTrialIndices)) return new Set(multi.jumps.map(function (_, i) { return i; }));
  const stable = new Set(selectedTrialIndices.map(function (id) { return String(id); }));
  const positions = new Set();
  multi.jumps.forEach(function (jump, i) { if (stable.has(String(jump.index))) positions.add(i); });
  return positions;
}

// ── Value formatting (display only — no derivation) ──
function frpFmt(v, unit) {
  if (v == null || !isFinite(v)) return '—';
  const a = Math.abs(v);
  if (unit === 'ms' || (unit === '' && a >= 100)) return Math.round(v).toString();
  if (a >= 1000) return Math.round(v).toLocaleString();
  if (a >= 100) return v.toFixed(0);
  if (a >= 10) return v.toFixed(1);
  return v.toFixed(2);
}
function frpDate(d) { return d ? String(d).slice(0, 10) : '—'; }

// FORCE-WS-1b (2026-07-10): a metric label that carries the def's formulaTip as a
// hover tip (.frp-tip → dotted underline + CSS ::after tooltip, mirroring the
// finalized mockup's .tip). Computed metrics only carry formulaTip (see cmj/sj/imtp
// defs); direct-read metrics render as plain text. Screen-only — a print rule strips
// the underline (hover has no meaning on paper). This is the label node ONLY (unit is
// appended by the caller). Applies to surfaces WE own here (5b/5c compare tables +
// the *-metrics module rows). The analysis-pane PANEL tables are untouched this slice
// (they gain tips when the panel-restructuring slice lands).
function frpTipLabel(def) {
  if (!def) return '';
  return def.formulaTip
    ? <span className="frp-tip" data-tip={def.formulaTip}>{def.label}</span>
    : def.label;
}

// ── FORCE-5a · metric multi-select + presentation switcher plumbing ──────────
// The three *-metrics modules share one config surface: which metric keys are
// shown + how they are presented. Defs are read from the SAME live window
// globals ForceSessionSource reads (never copied).
const FRP_VIZ_MODES = Object.freeze([
  Object.freeze({ id: 'table', label: '表格' }),
  Object.freeze({ id: 'bars', label: '柱状' }),
  Object.freeze({ id: 'trend', label: '趋势' }),
  Object.freeze({ id: 'bars-trend', label: '柱状+趋势' }),
]);

const FRP_METRIC_MODULES = Object.freeze({
  'cmj-metrics': Object.freeze({ typeId: 'cmj', label: 'CMJ' }),
  'sj-metrics': Object.freeze({ typeId: 'sj', label: 'SJ' }),
  'imtp-metrics': Object.freeze({ typeId: 'imtp', label: 'IMTP' }),
});

const FRP_PROFILE_MODULES = Object.freeze({
  'cmj-metric-profile': Object.freeze({ typeId: 'cmj', label: 'CMJ' }),
  'sj-metric-profile': Object.freeze({ typeId: 'sj', label: 'SJ' }),
  'imtp-metric-profile': Object.freeze({ typeId: 'imtp', label: 'IMTP' }),
});

// FORCE-5b · the per-tab 个体内多会话对比 modules. Same shape as FRP_METRIC_MODULES
// so the metric-chip idiom (frpMetricDefs / metricSel) is reused verbatim; the
// added surface is session multi-select (compareSel) + the adjacency-Δ table.
const FRP_COMPARE_MODULES = Object.freeze({
  'cmj-session-compare': Object.freeze({ typeId: 'cmj', label: 'CMJ' }),
  'sj-session-compare': Object.freeze({ typeId: 'sj', label: 'SJ' }),
  'imtp-session-compare': Object.freeze({ typeId: 'imtp', label: 'IMTP' }),
});

// FORCE-5c · the per-tab 个体间对比 modules. Same shape/keying as the two families
// above so the metric-chip idiom (frpMetricDefs / metricSel) is reused verbatim;
// the added surface is an athlete multi-select (athleteCompareSel) + a 指标行 ×
// 运动员列 table (raw values side-by-side, no ranking / no coloring).
const FRP_ATHLETE_COMPARE_MODULES = Object.freeze({
  'cmj-athlete-compare': Object.freeze({ typeId: 'cmj', label: 'CMJ' }),
  'sj-athlete-compare': Object.freeze({ typeId: 'sj', label: 'SJ' }),
  'imtp-athlete-compare': Object.freeze({ typeId: 'imtp', label: 'IMTP' }),
});

// FORCE-5d · the 曲线对比 module (CMJ only — SJ/IMTP store no force-time curve). Its
// selection is NOT a new system: 会话对比 reuses the 5b compareSel map; 运动员对比
// reuses the 5c athleteCompareSel map (+ frpAthleteCompareColumns) — both keyed by
// this module id. The only added per-module state is a presentation flag (curveMode).
const FRP_CURVE_COMPARE_MODULES = Object.freeze({
  'curve-compare': Object.freeze({ typeId: 'cmj', label: 'CMJ' }),
});

// Fixed neutral series palette — IDENTITY colors (one hue per overlaid curve), never
// a ranking/good-bad scale. Length 5 covers both caps (5 sessions / 本人+4 athletes).
const FRP_CURVE_PALETTE = Object.freeze(['#3b82f6', '#0ea5e9', '#14b8a6', '#8b5cf6', '#f59e0b']);

// Test-type id backing a metrics / session-compare / athlete-compare module (null
// otherwise). All three module families are keyed by the SAME test type, so metric
// defs / series read-throughs are shared.
function frpModuleTypeId(moduleId) {
  if (FRP_METRIC_MODULES[moduleId]) return FRP_METRIC_MODULES[moduleId].typeId;
  if (FRP_PROFILE_MODULES[moduleId]) return FRP_PROFILE_MODULES[moduleId].typeId;
  if (FRP_COMPARE_MODULES[moduleId]) return FRP_COMPARE_MODULES[moduleId].typeId;
  if (FRP_ATHLETE_COMPARE_MODULES[moduleId]) return FRP_ATHLETE_COMPARE_MODULES[moduleId].typeId;
  return null;
}

// Complete metric defs for a metrics/compare module. ForceSessionSource owns the
// catalog so report, saved analysis and athlete trends cannot drift.
function frpMetricDefs(moduleId) {
  const typeId = frpModuleTypeId(moduleId);
  const source = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
  if (typeId && source && typeof source.getMetricDefinitions === 'function') return source.getMetricDefinitions(typeId);
  if (typeId === 'cmj') {
    const cmjInternals = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
    return Array.isArray(cmjInternals.ALL_SUMMARY_METRICS) ? cmjInternals.ALL_SUMMARY_METRICS : [];
  }
  if (typeId === 'sj') return Array.isArray(window.SJ_SUMMARY_METRICS) ? window.SJ_SUMMARY_METRICS : [];
  if (typeId === 'imtp') return Array.isArray(window.IMTP_SUMMARY_METRICS) ? window.IMTP_SUMMARY_METRICS : [];
  return null;
}

function frp_getMetricValue(session, typeId, metricKey) {
  const FSS = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
  if (FSS && typeof FSS.getMetricValue === 'function') return FSS.getMetricValue(session, typeId, metricKey);
  if (!session) return null;
  const bestValue = session.best && typeof session.best === 'object' ? session.best[metricKey] : null;
  if (bestValue != null && isFinite(bestValue)) return bestValue;
  const legacyValue = session.metrics && typeof session.metrics === 'object' ? session.metrics[metricKey] : null;
  return (legacyValue != null && isFinite(legacyValue)) ? legacyValue : null;
}

// Delegate representative resolution to the shared force read model. The report
// keeps this narrow wrapper because it also runs in isolated preview harnesses
// where the core module can intentionally be absent.
function frpEffectiveReportSession(session) {
  const source = (typeof window !== 'undefined' && window.ForceSessionSource) || null;
  return source && typeof source.resolveEffectiveSession === 'function'
    ? source.resolveEffectiveSession(session)
    : (session || null);
}

function frpEffectiveReportInputs(inputs) {
  const source = inputs || {};
  const result = Object.assign({}, source);
  Object.keys(result).forEach(function (key) {
    if (Array.isArray(result[key])) result[key] = result[key].map(frpEffectiveReportSession);
  });
  return result;
}

function frpEffectiveReportSelection(selection) {
  const result = {};
  Object.keys(selection || {}).forEach(function (typeId) {
    result[typeId] = frpEffectiveReportSession(selection[typeId]);
  });
  return result;
}

function frpRepresentativeTrial(session) {
  const trials = session && Array.isArray(session.trials) ? session.trials : [];
  if (!trials.length) return null;
  const repIndex = session && session.representative ? session.representative.index : null;
  return trials.find(function (trial) { return String(trial.index) === String(repIndex); }) || trials[0];
}

// Read the same saved representative-trial value the analysis face uses. Relative
// metrics are derived only through ForceMetricCatalog from that saved metric map +
// protocol body mass; missing facts stay missing and never fall back to recomputation.
function frpRepresentativeMetricValue(session, typeId, metricKey, definitions) {
  const trial = frpRepresentativeTrial(session);
  const metrics = trial && trial.metrics ? trial.metrics : null;
  if (metrics) {
    const direct = metrics[metricKey];
    if (typeof direct === 'number' && isFinite(direct)) return direct;
    const catalog = (typeof window !== 'undefined' && window.ForceMetricCatalog) || null;
    if (catalog && typeof catalog.metricValue === 'function') {
      const mass = session && session.protocol ? session.protocol.bodyMass : null;
      const derived = catalog.metricValue(metrics, typeId, metricKey, mass, definitions || []);
      if (typeof derived === 'number' && isFinite(derived)) return derived;
    }
  }
  return frp_getMetricValue(session, typeId, metricKey);
}

function frpMetricProfiles(typeId, session) {
  if (!session) return [];
  const defs = frpMetricDefs(typeId + '-metrics') || [];
  const rows = defs.map(function (def) {
    return { def: def, v: frpRepresentativeMetricValue(session, typeId, def.key, defs) };
  }).filter(function (row) { return row.v != null; });
  const stats = (typeof window !== 'undefined' && window.ForceTrialStatistics) || null;
  return stats && typeof stats.buildMetricProfiles === 'function' ? stats.buildMetricProfiles(rows) : [];
}

function frpEffectiveProfileSection(profiles, requested) {
  const stats = (typeof window !== 'undefined' && window.ForceTrialStatistics) || null;
  return stats && typeof stats.preferredMetricProfileSection === 'function'
    ? stats.preferredMetricProfileSection(profiles, requested) : null;
}

function frpSelectedProfileSections(profiles, requested) {
  const available = new Set((profiles || []).map(function (profile) { return profile.section; }));
  if (Array.isArray(requested)) {
    return requested.filter(function (section, index, list) {
      return available.has(section) && list.indexOf(section) === index;
    });
  }
  const fallback = frpEffectiveProfileSection(profiles, requested);
  return fallback ? [fallback] : [];
}

function frpFilterMetricProfile(profile, selectedKeys) {
  if (!profile) return null;
  const groups = (profile.groups || []).map(function (group) {
    const rows = (group.rows || []).filter(function (row) { return selectedKeys.has(row.def.key); });
    if (!rows.length) return null;
    return Object.assign({}, group, {
      rows: rows,
      maxAbs: Math.max.apply(null, rows.map(function (row) { return Math.abs(row.v); })) || 1,
    });
  }).filter(Boolean);
  return groups.length ? { section: profile.section, groups: groups } : null;
}

function frpReferenceForModule(moduleId, ctx, definitions) {
  const model = window.ForceReportReferenceModel || null;
  const baseConfig = ctx.referenceConfig ? ctx.referenceConfig[moduleId] : null;
  const cohortByTarget = baseConfig && baseConfig.cohortAthleteIdsByTarget;
  const targetId = ctx.athlete && String(ctx.athlete.id);
  const config = cohortByTarget && targetId
    ? Object.assign({}, baseConfig, { athleteIds: Array.isArray(cohortByTarget[targetId]) ? cohortByTarget[targetId] : [] })
    : baseConfig;
  const typeId = frpModuleTypeId(moduleId);
  if (!model || !config || !typeId || config.mode === 'none') return null;
  const sessionsByAthlete = {};
  if (config.mode === 'cohort' && typeof ctx.inputsForAthlete === 'function') {
    (ctx.roster || []).forEach(function (athlete) {
      const athleteInputs = ctx.inputsForAthlete(athlete.id);
      sessionsByAthlete[athlete.id] = (window.ForceSessionSource.listSessions(athleteInputs, typeId) || [])
        .map(function (entry) { return entry.session; });
    });
  }
  return model.buildReference({
    config: config,
    typeId: typeId,
    metricDefs: definitions,
    normRecords: ctx.normRecords || [],
    athletes: ctx.roster || [],
    sessionsByAthlete: sessionsByAthlete,
    valueOf: function (session, metricKey) { return frp_getMetricValue(session, typeId, metricKey); },
  });
}

function frpReferenceText(reference, unit) {
  if (!reference) return '—';
  const mean = frpFmt(reference.mean, unit);
  return reference.sd != null
    ? mean + ' ± ' + frpFmt(reference.sd, unit)
    : mean;
}

const FRP_CMJ_KEYPOINTS_ZH = Object.freeze([
  Object.freeze({ key: 'a', name: '动作开始', meaning: '检测到起跳动作开始' }),
  Object.freeze({ key: 'b', name: '最低力', meaning: '卸载期地面反作用力最低点' }),
  Object.freeze({ key: 'c', name: '最低速度', meaning: '向下速度峰值，卸载阶段结束' }),
  Object.freeze({ key: 'd', name: '峰值力', meaning: '推进期最大地面反作用力' }),
  Object.freeze({ key: 'e', name: '最低位置', meaning: '重心最低点，制动结束并进入推进' }),
  Object.freeze({ key: 'f', name: '峰值速度', meaning: '向上速度达到最大值' }),
  Object.freeze({ key: 'g', name: '离板', meaning: '双脚离开力台' }),
]);

const FRP_CMJ_TYPE_LABELS = Object.freeze({
  'Ⅰ': '单峰 · 力优型',
  'Ⅱ': '单峰 · 力不足型',
  'Ⅲ': '双峰 · 力优型',
  'Ⅳ': '双峰 · 力不足型',
});
const FRP_CMJ_TYPE_DESCRIPTIONS = Object.freeze({
  'Ⅰ': '推进期呈单一主要力峰，峰值力出现在重心最低点附近。说明运动员能在制动—推进转换早期较快地组织并输出力量，力—时序结构相对集中。',
  'Ⅱ': '推进期呈单一主要力峰，但峰值力出现在重心最低点之后。说明主要力量输出相对延迟，早期推进阶段的力表达可能不足，或发力时序偏晚。',
  'Ⅲ': '推进期呈两个可辨识力峰，主要峰值出现在重心最低点附近。说明早期力量输出较充分，但推进过程中存在第二次力峰，可能反映分段发力、动作策略或关节贡献变化。',
  'Ⅳ': '推进期呈两个可辨识力峰，主要峰值未出现在重心最低点附近。说明早期推进力量输出相对不足或延迟，同时存在较明显的分段发力特征。',
});
const FRP_CMJ_CLASSIFICATION_DISCLAIMER = '本分型描述推进期力曲线形态及峰值力出现时序。“力优/力不足”不代表绝对力量水平高低，也不应单独作为训练处方依据；解读时应结合相对峰值力、相对 RFD、冲量、跳跃高度及动作策略综合判断。';

function frpPercentile(value, target) {
  if (!target || !window.ForceReportReferenceModel) return null;
  const comparison = window.ForceReportReferenceModel.compare(value, target);
  return comparison && comparison.percentile != null ? comparison.percentile : null;
}

function frpPercentileText(value, target) {
  const percentile = frpPercentile(value, target);
  return percentile == null ? '—' : ('P' + Math.round(percentile));
}

function frpSortRowsByPercentile(rows, reference) {
  return (rows || []).slice().sort(function (a, b) {
    const aPercentile = frpPercentile(a.v, reference && reference.get(a.def.key));
    const bPercentile = frpPercentile(b.v, reference && reference.get(b.def.key));
    if (aPercentile == null && bPercentile == null) return 0;
    if (aPercentile == null) return 1;
    if (bPercentile == null) return -1;
    return bPercentile - aPercentile;
  });
}

function ForceReferencePopulation({ reference }) {
  return reference && reference.population
    ? <div className="frp-reference-population" data-frp-reference-population>{reference.population}</div>
    : null;
}

function ForceMetricReferenceCells({ value, definition, reference, showPercentile }) {
  const target = reference ? reference.get(definition.key) : null;
  const comparison = target && window.ForceReportReferenceModel
    ? window.ForceReportReferenceModel.compare(value, target) : null;
  return (
    <>
      <span className={'frp-ref-value frp-metric-number' + (target ? '' : ' frp-ref-missing')}>{frpReferenceText(target, definition.unit)}</span>
      <span className={'frp-ref-delta frp-metric-number' + (comparison ? '' : ' frp-ref-missing')}>
        {comparison ? ((comparison.delta > 0 ? '+' : '') + frpFmt(comparison.delta, definition.unit)) : '—'}
      </span>
      {showPercentile ? <span className="frp-percentile frp-metric-number" data-frp-percentile={definition.key}>{frpPercentileText(value, target)}</span> : null}
    </>
  );
}

function ForceMetricProfile({ profile, reference, percentileSort }) {
  if (!profile) return null;
  return (
    <div className="frp-profile-grid">
      {profile.groups.map(function (group) {
        return (
          <div className="frp-profile-group" key={group.unit}>
            <div className="frp-profile-unit">{group.unit}</div>
            {(percentileSort ? frpSortRowsByPercentile(group.rows, reference) : group.rows).map(function (row) {
              const target = reference ? reference.get(row.def.key) : null;
              const scale = Math.max(group.maxAbs, target ? Math.abs(target.mean) : 0, 1e-9);
              return (
                <div className={'frp-profile-row' + (reference ? ' has-reference' : '') + (percentileSort ? ' show-percentile' : '')} key={row.def.key} data-metric-key={row.def.key}>
                  <span className="frp-profile-label" title={row.def.label}>{row.def.label}</span>
                  <span className="frp-profile-track">
                    <i style={{ width: Math.max(2, Math.abs(row.v) / scale * 100) + '%' }} />
                    {target ? <b title={'参考 ' + frpReferenceText(target, row.def.unit)} style={{ left: Math.min(100, Math.abs(target.mean) / scale * 100) + '%' }} /> : null}
                  </span>
                  <span className="frp-profile-value">{frpFmt(row.v, row.def.unit)}</span>
                  {reference ? <span className="frp-profile-reference">{target ? frpReferenceText(target, row.def.unit) : '—'}</span> : null}
                  {percentileSort ? <span className="frp-percentile" data-frp-percentile={row.def.key}>{frpPercentileText(row.v, target)}</span> : null}
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

// Selected-key set for a module. metricSel[moduleId] undefined ⇒ ALL selected
// (the default); an explicit array (possibly empty) ⇒ exactly those keys.
function frpSelectedKeySet(metricSel, moduleId, defs) {
  const sel = metricSel ? metricSel[moduleId] : undefined;
  if (!Array.isArray(sel)) return new Set(defs.map(function (d) { return d.key; }));
  return new Set(sel);
}

// Per-metric session series, chronological ASCENDING, via the sanctioned
// ForceSessionSource paths only. Malformed dates are filtered (pit #15:
// +new Date(junk) is NaN — drop, never let it scramble the axis); sessions
// without a finite raw value for the key contribute nothing.
function frpMetricSeries(inputs, typeId, metricKey) {
  const FSS = window.ForceSessionSource || null;
  if (!FSS || typeof FSS.listSessions !== 'function') return [];
  return FSS.listSessions(inputs, typeId) // newest-first
    .filter(function (r) { return !Number.isNaN(+new Date(r.date)); })
    .reverse() // → chronological ascending
    .map(function (r) { return { id: r.id, date: r.date, value: frp_getMetricValue(r.session, typeId, metricKey) }; })
    .filter(function (p) { return p.value != null; });
}

// ── FORCE-5a · THE single parameterized chart renderer (柱状 / 趋势 / 柱状+趋势
//    are all this one component: showBars / showLine flags). Pure SVG, print-safe
//    (no hover dependency: first + last raw value labels are always drawn).
//    RED LINE: y-scale is THIS metric's own raw min→max — values are plotted
//    verbatim, never rescaled against other metrics (MB-3 ruling). ──
function ForceMetricSeriesChart({ points, showBars, showLine, selectedId }) {
  const W = 220, H = 84, PL = 8, PR = 8, PT = 16, PB = 6;
  const n = points.length;
  if (!n) return null;
  const vals = points.map(function (p) { return p.value; });
  let vmin = Math.min.apply(null, vals), vmax = Math.max.apply(null, vals);
  if (vmin === vmax) { const pad = Math.abs(vmin) * 0.1 || 1; vmin -= pad; vmax += pad; }
  else { const pad = (vmax - vmin) * 0.1; vmin -= pad; vmax += pad; }
  const y = function (v) { return H - PB - ((v - vmin) / (vmax - vmin)) * (H - PT - PB); };
  const slot = (W - PL - PR) / n;
  const cx = function (i) { return PL + slot * (i + 0.5); };
  const barW = Math.min(24, slot * 0.64);
  const isSel = function (p) { return selectedId != null && p.id != null && String(p.id) === String(selectedId); };
  const lastIdx = n - 1;
  return (
    <svg viewBox={'0 0 ' + W + ' ' + H} style={{ width: '100%', height: 'auto', display: 'block', marginTop: 6 }} aria-hidden="true">
      {/* hairline baseline */}
      <line x1={PL} y1={H - PB} x2={W - PR} y2={H - PB} stroke="#e2e8f0" strokeWidth="1" />
      {showBars && points.map(function (p, i) {
        const yy = y(p.value);
        return (
          <rect key={'b' + i}
            x={cx(i) - barW / 2} y={yy}
            width={barW} height={Math.max(1, H - PB - yy)}
            rx="2"
            fill={isSel(p) ? 'var(--accent, #2563eb)' : '#cbd5e1'}
          />
        );
      })}
      {showLine && n > 1 && (
        <polyline
          points={points.map(function (p, i) { return cx(i) + ',' + y(p.value); }).join(' ')}
          fill="none" stroke="#1e40af" strokeWidth="1.5"
        />
      )}
      {showLine && (
        <circle cx={cx(lastIdx)} cy={y(points[lastIdx].value)} r="2.5" fill="#1e40af" />
      )}
      {/* first + last raw value labels (print has no hover — always drawn) */}
      <text x={cx(0)} y={y(points[0].value) - 4} textAnchor={n > 1 ? 'start' : 'middle'}
        style={{ font: '8.5px var(--font-mono, monospace)', fill: '#475569' }}>{frpFmt(points[0].value)}</text>
      {n > 1 && (
        <text x={cx(lastIdx)} y={y(points[lastIdx].value) - 4} textAnchor="end"
          style={{ font: '8.5px var(--font-mono, monospace)', fill: '#475569' }}>{frpFmt(points[lastIdx].value)}</text>
      )}
    </svg>
  );
}

// FORCE-5a · chart-mode grid: one cell per selected metric (label + mini chart +
// first→last raw values). Shared by all three *-metrics modules — the mode maps
// onto the single renderer's two flags.
function renderForceMetricChartGrid(moduleId, typeId, defs, mode, inputs, selectedSession, reference) {
  const showBars = mode === 'bars' || mode === 'bars-trend';
  const showLine = mode === 'trend' || mode === 'bars-trend';
  const selectedId = selectedSession && selectedSession.id != null ? selectedSession.id : null;
  return (
    <div className="frp-viz-grid">
      {defs.map(function (d) {
        const points = frpMetricSeries(inputs, typeId, d.key);
        const first = points[0], last = points[points.length - 1];
        return (
          <div className="frp-viz-cell" key={d.key}>
            <div className="frp-viz-lbl">{d.label}{d.unit ? <span style={{ fontWeight: 400, color: 'var(--muted)', marginLeft: 4, fontSize: 10.5 }}>{d.unit}</span> : null}</div>
            {points.length
              ? <ForceMetricSeriesChart points={points} showBars={showBars} showLine={showLine} selectedId={selectedId} />
              : <div className="frp-absent" style={{ padding: '6px 0' }}>各会话均无该指标数据。</div>}
            {points.length ? (
              <div className="frp-viz-range">
                {frpDate(first.date)} {frpFmt(first.value, d.unit)} → {frpDate(last.date)} {frpFmt(last.value, d.unit)}{d.unit ? ' ' + d.unit : ''}
              </div>
            ) : null}
            {points.length && reference && reference.get(d.key) ? (function () {
              const ref = reference.get(d.key);
              const comparison = window.ForceReportReferenceModel.compare(last.value, ref);
              return <div className="frp-viz-range" data-frp-viz-reference={d.key}>
                {reference.label} {frpReferenceText(ref, d.unit)} · 差值 {comparison ? frpDeltaText(comparison.delta, d.unit) : '—'}
              </div>;
            })() : null}
          </div>
        );
      })}
    </div>
  );
}

// ── FORCE-5b · 个体内多会话对比 plumbing ─────────────────────────────────────
// Session multi-select (default latest 3, hard cap 5 for print width), rendered
// as 指标行 × 会话列 + 相邻 Δ. RED LINES: raw values only (getMetricValue, no
// derivation/normalization); Δ color follows the metric def's `better` field —
// mirror of the catalog's read-through (better==='lower' ? delta<0 : delta>0) —
// neutral/zero → gray. No severity classes.
const FRP_COMPARE_MAX = 5; // print-width guard: at most 5 session columns

// Comparability rule (GPT audit P1.2): rate-'limited' / invalid sessions are excluded from ALL
// cross-session report comparison — never enter the default selection nor a valid explicit pick.
// Single rule via ForceSessionSource.
function frpComparableList(sessionList) {
  const FSS = (typeof window !== 'undefined') ? window.ForceSessionSource : null;
  if (!FSS || !FSS.sessionComparability) return sessionList || [];
  return (sessionList || []).filter(function (r) { return FSS.sessionComparability(r && r.session ? r.session : r).comparable; });
}

// Latest N COMPARABLE ids from a newest-first session list (default selection).
function frpCompareDefaultIds(sessionList) {
  return frpComparableList(sessionList).slice(0, 3).map(function (r) { return r.id; });
}

// Resolve the selected session id list for a compare module. Undefined ⇒ default
// (latest 3 / all if fewer). Explicit array ⇒ exactly those, filtered to sessions
// that still exist and capped to FRP_COMPARE_MAX (defensive — the toggle enforces
// the cap live, this keeps the render honest).
function frpCompareSelectedIds(compareSel, moduleId, sessionList) {
  const sel = compareSel ? compareSel[moduleId] : undefined;
  if (!Array.isArray(sel)) return frpCompareDefaultIds(sessionList);
  // valid = only COMPARABLE sessions — a limited/invalid session can't be selected for comparison.
  const valid = new Set(frpComparableList(sessionList).map(function (r) { return String(r.id); }));
  return sel.filter(function (id) { return valid.has(String(id)); }).slice(0, FRP_COMPARE_MAX);
}

// Δ color read-through: mirrors the direction rule dir==='lower' ? delta<0 : delta>0
// against the def's `better` field. null/undefined delta OR better==null OR a zero
// delta → neutral (gray). Returns a raw CSS color token (no severity class).
function frpDeltaColor(better, delta) {
  if (delta == null || !isFinite(delta) || delta === 0 || !better) return 'var(--muted)';
  const improved = better === 'lower' ? delta < 0 : delta > 0;
  return improved ? 'var(--pos)' : 'var(--neg)';
}

// Signed raw Δ text (末次 − 前次), e.g. '+3.2' / '-0.05'. null → '/'.
function frpDeltaText(delta, unit) {
  if (delta == null || !isFinite(delta)) return '/';
  const body = frpFmt(Math.abs(delta), unit);
  return (delta > 0 ? '+' : delta < 0 ? '−' : '') + body + (unit ? ' ' + unit : '');
}

// ── FORCE-5c · 个体间对比 plumbing ────────────────────────────────────────────
// Athlete multi-select (default NONE — manual add), hard cap 4 comparison athletes
// for print width. RED LINES: raw values only (getMetricValue, no derivation); NO
// ranking score, NO weighting, NO best-value mark, NO cross-athlete coloring — every
// value cell is ink-neutral. Each column header MANDATORILY carries the source
// session date (mockup rule). Missing metric OR zero-session column → '/'.
const FRP_ATHLETE_COMPARE_MAX = 4; // print-width guard: at most 4 comparison athletes

// Resolve the selected comparison-athlete id list for an athlete-compare module.
// Undefined ⇒ [] (no comparison athletes — the honest '选择对比运动员' empty state).
// Explicit array ⇒ those ids, defensively capped to FRP_ATHLETE_COMPARE_MAX (the
// toggle enforces the cap live; this keeps the render honest).
function frpAthleteCompareSelectedIds(athleteCompareSel, moduleId) {
  const sel = athleteCompareSel ? athleteCompareSel[moduleId] : undefined;
  if (!Array.isArray(sel)) return [];
  return sel.slice(0, FRP_ATHLETE_COMPARE_MAX);
}

// Build the ordered column descriptors for an athlete-compare table: current
// athlete first (session = the workbench's selected session for the type), then each
// comparison athlete using their LATEST session of the type. A column with no session
// is flagged `zero` (renders date '—' + all '/' + a footnote entry). Reads other
// athletes' sessions ONLY via the sanctioned inputsForAthlete → ForceSessionSource
// path (per-athlete arrays constructed in app-modals, the threading choke point).
function frpAthleteCompareColumns(ctx, typeId, moduleId) {
  const FSS = window.ForceSessionSource || null;
  const roster = Array.isArray(ctx.roster) ? ctx.roster : [];
  const selIds = frpAthleteCompareSelectedIds(ctx.athleteCompareSel, moduleId);
  const curSession = (ctx.selection && ctx.selection[typeId]) || null;
  // Comparability (GPT audit P1.2): a rate-limited / invalid session must NOT enter the cross-
  // athlete table (or the athlete-mode curve overlay, which is built from these columns). The
  // current athlete uses its selected session ONLY if comparable; each comparison athlete uses
  // their latest COMPARABLE session (not merely their latest). No comparable session → empty
  // column + honest reason.
  const curCmp = (FSS && curSession && FSS.sessionComparability) ? FSS.sessionComparability(curSession) : { comparable: true, reason: null };
  const curUsable = (curSession && curCmp.comparable) ? curSession : null;
  const cols = [{
    kind: 'current', athlete: ctx.athlete || null, session: curUsable,
    date: curUsable ? curUsable.date : null, zero: !curUsable,
    reason: (!curUsable && curSession) ? curCmp.reason : null,
  }];
  selIds.forEach(function (aid) {
    const a = roster.find(function (r) { return String(r.id) === String(aid); });
    if (!a) return; // roster shifted — drop silently (defensive)
    const inp = (typeof ctx.inputsForAthlete === 'function') ? ctx.inputsForAthlete(aid) : null;
    const list = (inp && FSS) ? FSS.listSessions(inp, typeId) : []; // newest-first
    const comparable = (FSS && FSS.comparableSessions) ? FSS.comparableSessions(list) : list;
    const latest = comparable.length ? comparable[0].session : null;
    cols.push({
      kind: 'compare', athlete: a, session: latest,
      date: latest ? latest.date : null, zero: !latest,
      reason: (!latest && list.length) ? '无可比会话 · 采样率不足/异常' : null,
    });
  });
  return cols;
}

// ── FORCE-5d · 曲线对比 plumbing ──────────────────────────────────────────────
// Build the ordered curve entries for the overlay from the workbench's EXISTING
// selection state — NO new selection system:
//   · 会话对比 (session mode): the current athlete's selected CMJ sessions (5b
//     compareSel, latest-3 default, cap 5), chronological ascending. Legend = date.
//   · 运动员对比 (athlete mode): 本人 + selected comparison athletes (5c
//     athleteCompareSel via frpAthleteCompareColumns, cap 4), each using their own
//     session (本人 = workbench-selected; others = latest). Legend = 姓名 · 日期.
// Each entry carries the session's REPRESENTATIVE trial (the one with a stored curve)
// — plotted verbatim from trial.curve (already stored in GRF/BW per-bodyweight units,
// as written at save time). No reconstruction, no re-derivation.
function frpCurveRepTrial(session) {
  const trials = (session && session.trials) || [];
  const repIdx = session && session.representative ? session.representative.index : undefined;
  return trials.find(function (t) { return t.index === repIdx && t.curve && t.curve.t && t.curve.t.length; })
      || trials.find(function (t) { return t.curve && t.curve.t && t.curve.t.length; })
      || null;
}

function frpBuildCurveEntries(ctx, mode, moduleId) {
  const FSS = window.ForceSessionSource || null;
  const typeId = 'cmj';
  const out = [];
  if (mode === 'athlete') {
    // cols[0] = 本人 (workbench-selected session); cols[1..] = comparison athletes.
    const cols = frpAthleteCompareColumns(ctx, typeId, moduleId);
    cols.forEach(function (c) {
      const trial = c.zero ? null : frpCurveRepTrial(c.session);
      out.push({
        athlete: c.athlete, session: c.session, trial: trial,
        legend: (c.athlete && c.athlete.name) || '—',
        sub: (c.kind === 'current' ? '本人 · ' : '') + frpDate(c.date),
        date: c.date,
      });
    });
  } else {
    const list = FSS ? FSS.listSessions(ctx.inputs, typeId) : []; // newest-first
    const selIds = frpCompareSelectedIds(ctx.compareSel, moduleId, list);
    selIds
      .map(function (id) { return list.find(function (r) { return String(r.id) === String(id); }); })
      .filter(Boolean)
      .sort(function (a, b) { return (+new Date(a.date)) - (+new Date(b.date)); })
      .forEach(function (r) {
        out.push({
          athlete: ctx.athlete, session: r.session, trial: frpCurveRepTrial(r.session),
          legend: frpDate(r.date), sub: null, date: r.date,
        });
      });
  }
  out.forEach(function (e, i) { e.color = FRP_CURVE_PALETTE[i % FRP_CURVE_PALETTE.length]; });
  return out;
}

// ── FORCE-5d · force-time overlay renderer. VERBATIM mirror of the analysis-page
//    测力台-对比 F-t overlay (cmj.jsx ForceCompareView) — same layout, same GRF/BW
//    axis, same a–g key-point markers. Pure presentation of ALREADY-STORED samples
//    in per-bodyweight units (trial.curve.t 0–1, trial.curve.f = GRF/BW) — NO
//    reconstruction, NO curve math. Print-safe (static SVG). ──
function ForceCurveOverlay({ entries }) {
  const withCurve = entries.filter(function (e) { return e.trial && e.trial.curve && e.trial.curve.t && e.trial.curve.t.length; });
  if (!withCurve.length) return null;
  const W = 760, H = 320, ML = 52, MR = 18, MT = 22, MB = 42;
  const PW = W - ML - MR, PH = H - MT - MB;
  const allF = withCurve.reduce(function (a, e) { return a.concat(e.trial.curve.f || []); }, []).filter(function (v) { return isFinite(v); });
  let lo = Math.min.apply(null, allF), hi = Math.max.apply(null, allF);
  const pad = (hi - lo) * 0.1 || Math.abs(hi) * 0.1 || 0.05; lo -= pad; hi += pad;
  const yS = function (f) { return MT + PH * (1 - (f - lo) / (hi - lo)); };
  const xS = function (t) { return ML + t * PW; };
  const niceTicks = function (a, b, n) {
    const span = b - a || 1; const raw = span / (n - 1);
    const mag = Math.pow(10, Math.floor(Math.log10(raw)));
    const step = [1, 2, 2.5, 5, 10].map(function (f) { return f * mag; }).find(function (s) { return s >= raw; }) || raw;
    const t = []; for (let v = Math.ceil(a / step) * step; v <= b + step * 0.01; v += step) t.push(+v.toFixed(10));
    return t;
  };
  const yTks = niceTicks(lo, hi, 6);
  const xTks = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
  const nearestIdx = function (tArr, target) {
    let bi = 0, bd = Infinity;
    for (let i = 0; i < tArr.length; i++) { const d = Math.abs(tArr[i] - target); if (d < bd) { bd = d; bi = i; } }
    return bi;
  };
  const kpLabels = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
  return (
    <svg viewBox={'0 0 ' + W + ' ' + H} style={{ width: '100%', display: 'block' }} aria-hidden="true">
      {yTks.map(function (y) { return (
        <g key={'y' + y}>
          <line x1={ML} y1={yS(y).toFixed(1)} x2={ML + PW} y2={yS(y).toFixed(1)} stroke="rgba(15,23,42,.06)" strokeWidth="1" />
          <text x={ML - 6} y={+yS(y).toFixed(1) + 3} textAnchor="end" fontSize="10.5" fill="var(--muted)">{y.toFixed(1)}</text>
        </g>
      ); })}
      {xTks.map(function (t) { return (
        <g key={'x' + t}>
          <line x1={xS(t).toFixed(1)} y1={MT} x2={xS(t).toFixed(1)} y2={MT + PH} stroke="rgba(15,23,42,.05)" strokeWidth="1" />
          <text x={xS(t).toFixed(1)} y={MT + PH + 14} textAnchor="middle" fontSize="10.5" fill="var(--muted)">{(t * 100).toFixed(0)}%</text>
        </g>
      ); })}
      {lo < 1 && hi > 1 && (
        <line x1={ML} y1={yS(1).toFixed(1)} x2={ML + PW} y2={yS(1).toFixed(1)} stroke="rgba(15,23,42,.22)" strokeWidth="1" strokeDasharray="5 3" />
      )}
      <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
      <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.12)" />
      {withCurve.map(function (e) {
        const c = e.trial.curve;
        const d = c.t.map(function (t, i) { return (i === 0 ? 'M' : 'L') + xS(t).toFixed(1) + ',' + yS(c.f[i]).toFixed(1); }).join('');
        return <path key={e.legend + e.date} d={d} fill="none" stroke={e.color} strokeWidth="1.8" strokeOpacity=".9" strokeLinejoin="round" strokeLinecap="round" />;
      })}
      {withCurve.map(function (e) {
        const c = e.trial.curve, kp = e.trial.keyPts;
        if (!kp) return null;
        return (
          <g key={'kp' + e.legend + e.date}>
            {kpLabels.map(function (lab) {
              const t = kp[lab];
              if (t == null) return null;
              const i = nearestIdx(c.t, t);
              const cx = xS(c.t[i]), cy = yS(c.f[i]);
              const above = cy > MT + PH * 0.5;
              return (
                <g key={lab}>
                  <circle cx={cx.toFixed(1)} cy={cy.toFixed(1)} r="3" fill="var(--bg)" stroke={e.color} strokeWidth="1.4" />
                  <text x={cx.toFixed(1)} y={(cy + (above ? -7 : 11)).toFixed(1)} textAnchor="middle" fontSize="11" fontWeight="700" fontStyle="italic" fill={e.color}>{lab}</text>
                </g>
              );
            })}
          </g>
        );
      })}
      <text x="14" y={MT + PH / 2} textAnchor="middle" fontSize="11" fill="var(--muted)" transform={'rotate(-90,14,' + (MT + PH / 2) + ')'}>GRF / BW</text>
      <text x={ML + PW / 2} y={H - 6} textAnchor="middle" fontSize="11" fill="var(--muted)">Time · onset → takeoff (0–100%)</text>
    </svg>
  );
}

// ── Derived-metric card. RED LINE: formula + every source date ALWAYS render;
//    a cross-day pairing (source dates differ) is marked, never hidden; missing
//    inputs → honest 数据不足 (still shows the formula + which sources). ──
function ForceDerivedCard({ entry, result }) {
  const sources = result && Array.isArray(result.sources) ? result.sources : null;
  const dates = sources ? sources.map(function (s) { return s.date; }) : [];
  const distinct = dates.filter(Boolean).filter(function (d, i, a) { return a.indexOf(d) === i; });
  const crossDay = distinct.length > 1; // 跨日: the two source sessions are different dates
  const inputRows = sources
    ? sources
    : entry.inputs.map(function (i) { return { type: i.type, date: null }; });
  const inputLabel = entry.inputs.reduce(function (m, i) { m[i.type] = i.label; return m; }, {});
  return (
    <div className="frp-dcard">
      <div className="frp-dlabel">{entry.label}</div>
      {result
        ? <div className="frp-dval">{frpFmt(result.value, entry.unit)}{entry.unit ? <span style={{ fontSize: 13, marginLeft: 4 }}>{entry.unit}</span> : null}</div>
        : <div className="frp-dval absent">数据不足 · 缺少来源会话</div>}
      <div className="frp-dformula">{entry.formula}</div>
      <div className="frp-dsource">
        {inputRows.map(function (s, i) {
          return (
            <div key={i}>
              {(inputLabel[s.type] || s.type.toUpperCase())} · {frpDate(s.date)}
              {i === 0 && crossDay ? <span className="frp-crossday">跨日配对</span> : null}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── Module renderer. Switches on module id → paper JSX. Kept in one place so
//    the canvas preview and the print body render identically (single source). ──
function renderForceModule(moduleId, ctx) {
  const { selection, inputs } = ctx;
  const FSS = window.ForceSessionSource || null;
  const DFR = window.DerivedForceMetricRegistry || null;

  // FORCE-5b · 个体内多会话对比: 指标行 × 会话列 (时间升序) + 相邻 Δ. Raw values via
  // getMetricValue; missing → '/'; Δ = 末次 − 前次 (both must be present, else '/'),
  // colored by the metric's `better` direction.
  if (FRP_COMPARE_MODULES[moduleId]) {
    const typeId = FRP_COMPARE_MODULES[moduleId].typeId;
    const label = FRP_COMPARE_MODULES[moduleId].label;
    const sessionList = FSS ? FSS.listSessions(inputs, typeId) : []; // newest-first
    const selIds = frpCompareSelectedIds(ctx.compareSel, moduleId, sessionList);
    // ≥2 sessions required — honest hint otherwise (1 selected / only 1 available).
    if (selIds.length < 2) {
      return <div className="frp-absent" key={moduleId}>至少选择两次会话（当前 {selIds.length} 次）· 在检查器中勾选。</div>;
    }
    // Columns chronological ASCENDING (header = date); Δ compares the last two.
    const cols = selIds
      .map(function (id) { return sessionList.find(function (r) { return String(r.id) === String(id); }); })
      .filter(Boolean)
      .sort(function (a, b) { return (+new Date(a.date)) - (+new Date(b.date)); });
    const allDefs = frpMetricDefs(moduleId) || [];
    const selSet = frpSelectedKeySet(ctx.metricSel, moduleId, allDefs);
    const defs = allDefs.filter(function (d) { return selSet.has(d.key); });
    if (!defs.length) return <div className="frp-absent" key={moduleId}>未选择指标 · 在检查器中勾选。</div>;
    const prevCol = cols[cols.length - 2], lastCol = cols[cols.length - 1];
    return (
      <div key={moduleId}>
        <div className="sec-h"><span className="lbl">{label} 多会话对比 · {cols.length} 次会话 · 时间升序 · 原始实测</span><span className="ln"></span></div>
        <div className="frp-cmp-wrap">
          <table className="frp-cmp">
            <thead>
              <tr>
                <th className="frp-cmp-metric">指标</th>
                {cols.map(function (c) { return <th key={String(c.id)}>{frpDate(c.date)}</th>; })}
                <th className="frp-cmp-delta">Δ 相邻</th>
              </tr>
            </thead>
            <tbody>
              {defs.map(function (d) {
                const vPrev = frp_getMetricValue(prevCol.session, typeId, d.key);
                const vLast = frp_getMetricValue(lastCol.session, typeId, d.key);
                // Δ only when BOTH of the last two sessions carry the metric.
                const delta = (vPrev != null && vLast != null) ? (vLast - vPrev) : null;
                return (
                  <tr key={d.key}>
                    <td className="frp-cmp-metric">{frpTipLabel(d)}{d.unit ? <span style={{ color: 'var(--muted)', marginLeft: 4, fontSize: 10.5 }}>{d.unit}</span> : null}</td>
                    {cols.map(function (c) {
                      const v = frp_getMetricValue(c.session, typeId, d.key);
                      return v == null
                        ? <td className="frp-cmp-miss" key={String(c.id)}>/</td>
                        : <td className="frp-cmp-v" key={String(c.id)}>{frpFmt(v, d.unit)}</td>;
                    })}
                    <td className="frp-cmp-delta" style={{ color: frpDeltaColor(d.better, delta) }}>{frpDeltaText(delta, d.unit)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    );
  }

  // FORCE-5c · 个体间对比: 指标行 × 运动员列. Current athlete first (using the
  // workbench-selected session for the type), then each comparison athlete using
  // their LATEST session. Raw values via getMetricValue; missing metric OR a
  // zero-session column → '/'. RED LINE: NO delta column, NO cross-athlete coloring
  // (every value cell is ink-neutral — cross-athlete color would imply ranking), NO
  // best-value mark. Column headers carry 姓名 · #号 · 会话日期 (mandatory).
  if (FRP_ATHLETE_COMPARE_MODULES[moduleId]) {
    const typeId = FRP_ATHLETE_COMPARE_MODULES[moduleId].typeId;
    const label = FRP_ATHLETE_COMPARE_MODULES[moduleId].label;
    const cols = frpAthleteCompareColumns(ctx, typeId, moduleId);
    // cols[0] is always the current athlete; comparison athletes are cols[1..]. No
    // comparison athlete selected ⇒ the honest '选择对比运动员' empty state.
    if (cols.length < 2) {
      return <div className="frp-absent" key={moduleId}>选择对比运动员 · 在检查器中勾选（最多 {FRP_ATHLETE_COMPARE_MAX} 名）。</div>;
    }
    const allDefs = frpMetricDefs(moduleId) || [];
    const selSet = frpSelectedKeySet(ctx.metricSel, moduleId, allDefs);
    const defs = allDefs.filter(function (d) { return selSet.has(d.key); });
    if (!defs.length) return <div className="frp-absent" key={moduleId}>未选择指标 · 在检查器中勾选。</div>;
    // Footnote: comparison athletes (never the current athlete) with zero sessions of
    // the type — their column is rendered honestly (— / /), and listed here.
    const zeroAthletes = cols.filter(function (c) { return c.kind === 'compare' && c.zero; })
      .map(function (c) { return (c.athlete && c.athlete.name) || '—'; });
    const jerseyOf = function (a) { return (a && a.jersey != null && a.jersey !== '') ? ('#' + String(a.jersey).padStart(2, '0')) : ''; };
    return (
      <div key={moduleId}>
        <div className="sec-h"><span className="lbl">{label} 个体间对比 · {cols.length} 名运动员 · 原始实测</span><span className="ln"></span></div>
        <div className="frp-cmp-wrap">
          <table className="frp-cmp">
            <thead>
              <tr>
                <th className="frp-cmp-metric">指标</th>
                {cols.map(function (c, i) {
                  const a = c.athlete, jn = jerseyOf(a);
                  return (
                    <th className={'frp-cmp-athlete' + (c.kind === 'current' ? ' cur' : '')} key={'h' + i}>
                      <span className="frp-cmp-anm">{(a && a.name) || '—'}{c.kind === 'current' ? '（本人）' : ''}</span>
                      <span className="frp-cmp-asub">{jn ? jn + ' · ' : ''}{frpDate(c.date)}</span>
                    </th>
                  );
                })}
              </tr>
            </thead>
            <tbody>
              {defs.map(function (d) {
                return (
                  <tr key={d.key}>
                    <td className="frp-cmp-metric">{frpTipLabel(d)}{d.unit ? <span style={{ color: 'var(--muted)', marginLeft: 4, fontSize: 10.5 }}>{d.unit}</span> : null}</td>
                    {cols.map(function (c, i) {
                      // Zero-session column OR a metric the session lacks → '/'.
                      const v = (!c.zero && FSS) ? FSS.getMetricValue(c.session, typeId, d.key) : null;
                      return v == null
                        ? <td className="frp-cmp-miss" key={'v' + i}>/</td>
                        : <td className="frp-cmp-v" key={'v' + i}>{frpFmt(v, d.unit)}</td>;
                    })}
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        {zeroAthletes.length
          ? <div className="frp-cmp-foot">注：{zeroAthletes.join('、')} 暂无 {label} 会话，其列以 — / / 呈现。</div>
          : null}
      </div>
    );
  }

  // FORCE-5d · 曲线对比: force-time (GRF/BW) overlay of the selected items'
  // representative-trial curves. Mode toggle (会话对比 / 运动员对比) picks whether the
  // reused 5b (compareSel) or 5c (athleteCompareSel) selection drives the entries.
  // Legend carries identity color + date (会话对比) or 姓名 · 日期 (运动员对比).
  if (FRP_CURVE_COMPARE_MODULES[moduleId]) {
    const label = FRP_CURVE_COMPARE_MODULES[moduleId].label;
    const mode = (ctx.curveMode && ctx.curveMode[moduleId]) || 'session';
    const modeLabel = mode === 'athlete' ? '运动员对比' : '会话对比';
    const entries = frpBuildCurveEntries(ctx, mode, moduleId);
    // Honest empty states BEFORE the overlay (≥2 items needed to compare).
    if (entries.length < 2) {
      return mode === 'athlete'
        ? <div className="frp-absent" key={moduleId}>运动员对比：选择对比运动员（首条固定为本人）· 在检查器中勾选（最多 {FRP_ATHLETE_COMPARE_MAX} 名）。</div>
        : <div className="frp-absent" key={moduleId}>会话对比：至少选择两次会话（当前 {entries.length} 次）· 在检查器中勾选（最多 {FRP_COMPARE_MAX} 次）。</div>;
    }
    const withCurve = entries.filter(function (e) { return e.trial && e.trial.curve && e.trial.curve.t && e.trial.curve.t.length; });
    if (withCurve.length < 2) {
      return <div className="frp-absent" key={moduleId}>所选项中可绘制曲线不足两条（会话未保留力-时间曲线）· 更换选择。</div>;
    }
    return (
      <div key={moduleId}>
        <div className="sec-h"><span className="lbl">{label} 曲线对比 · {modeLabel} · {withCurve.length} 条曲线 · GRF/BW（力÷体重）</span><span className="ln"></span></div>
        <div className="frp-curve-legend">
          {withCurve.map(function (e) {
            return (
              <span className="frp-cl-item" key={'lg' + e.legend + e.date}>
                <span className="frp-cl-sw" style={{ background: e.color }}></span>
                <span className="frp-cl-nm" style={{ color: e.color }}>{e.legend}</span>
                <span className="frp-cl-meta">{e.sub || (e.trial ? ('T' + e.trial.index + (e.trial.type ? ' · ' + e.trial.type : '')) : '')}</span>
              </span>
            );
          })}
        </div>
        <div className="frp-chart-wrap">
          <ForceCurveOverlay entries={entries} />
        </div>
        <div style={{ fontSize: 10, color: 'var(--muted-2, var(--muted))', fontStyle: 'italic', padding: '4px 2px 0' }}>
          注：叠加各所选项的代表性 trial 已存曲线（onset→takeoff 折算为 0–100% 时间轴，纵轴 GRF/BW=力÷体重，每条曲线按其自身体重折算）；无可绘制曲线的选项不计入。
        </div>
      </div>
    );
  }

  if (moduleId === 'derived-metrics') {
    const derived = (DFR && DFR.listDerived) ? DFR.listDerived() : [];
    if (!derived.length) return <div className="frp-absent" key="derived">未注册衍生指标。</div>;
    return (
      <div key="derived">
        <div className="sec-h"><span className="lbl">衍生指标 · 跨类型透明比值</span><span className="ln"></span></div>
        <div className="frp-derived-grid">
          {derived.map(function (entry) {
            const result = (DFR && DFR.computeDerived) ? DFR.computeDerived(entry.id, selection) : null;
            return <ForceDerivedCard key={entry.id} entry={entry} result={result} />;
          })}
        </div>
      </div>
    );
  }

  if (moduleId === 'type-overview') {
    const types = (FSS && FSS.listTestTypes) ? FSS.listTestTypes() : [];
    const withData = types.map(function (t) {
      const list = FSS.listSessions(inputs, t.id);
      return { t: t, latest: list.length ? list[0].session : null };
    }).filter(function (r) { return r.latest; });
    if (!withData.length) return <div className="frp-absent" key="overview">暂无任一测试类型的会话数据。</div>;
    return (
      <div key="overview">
        <div className="sec-h"><span className="lbl">类型对照表 · 各类型最新会话</span><span className="ln"></span></div>
        {withData.map(function (r) {
          const defs = (r.t.defs ? r.t.defs() : []).slice(0, 4);
          return (
            <div className="frp-overview-block" key={r.t.id}>
              <div className="frp-overview-head">
                <span className="frp-ot">{r.t.label}</span>
                <span className="frp-od">最新会话 · {frpDate(r.latest.date)}</span>
              </div>
              {defs.map(function (d) {
                const v = frp_getMetricValue(r.latest, r.t.id, d.key);
                return (
                  <div className="frp-overview-row" key={d.key}>
                    <span>{d.label}</span>
                    <span className="frp-orv">{frpFmt(v, d.unit)}{d.unit ? ' ' + d.unit : ''}</span>
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    );
  }

  if (FRP_PROFILE_MODULES[moduleId]) {
    const profileMeta = FRP_PROFILE_MODULES[moduleId];
    const typeId = profileMeta.typeId;
    const session = selection[typeId];
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 {profileMeta.label} 会话。</div>;
    const profiles = frpMetricProfiles(typeId, session);
    const requested = ctx.profileSection ? ctx.profileSection[moduleId] : null;
    const sections = frpSelectedProfileSections(profiles, requested);
    const definitions = frpMetricDefs(typeId + '-metrics') || [];
    const selectedKeys = frpSelectedKeySet(ctx.metricSel, moduleId, definitions);
    const selectedProfiles = sections.map(function (section) {
      const profile = profiles.find(function (item) { return item.section === section; }) || null;
      return frpFilterMetricProfile(profile, selectedKeys);
    }).filter(Boolean);
    if (!profiles.length) return <div className="frp-absent" key={moduleId}>该会话没有至少两个同类别、同单位的可呈现指标。</div>;
    if (!sections.length) return <div className="frp-absent" key={moduleId}>未选择指标剖面类别。</div>;
    if (!selectedProfiles.length) return <div className="frp-absent" key={moduleId}>已选剖面内没有勾选可呈现指标。</div>;
    const reference = frpReferenceForModule(moduleId, ctx, definitions);
    const percentileSort = !!(ctx.percentileSort && ctx.percentileSort[moduleId]) && !!reference;
    return (
      <div key={moduleId} data-frp-metric-profile={typeId} data-profile-sections={sections.join('|')}>
        <ForceReferencePopulation reference={reference} />
        {selectedProfiles.map(function (profile) {
          return (
            <section className="frp-profile-section" key={profile.section} data-profile-section={profile.section}>
              <div className="sec-h"><span className="lbl">{profileMeta.label} 指标剖面 · {profile.section} · 代表 trial</span><span className="ln"></span></div>
              <ForceMetricProfile profile={profile} reference={reference} percentileSort={percentileSort} />
            </section>
          );
        })}
        <div className="frp-cmp-foot">
          每个剖面仅在同单位内缩放 · 原始数值保留 · 不跨单位比较
          {reference ? (' · 橙线 = ' + reference.label + '（有匹配值的指标）') : ''}。
        </div>
      </div>
    );
  }

  if (moduleId === 'cmj-classification') {
    const session = selection.cmj;
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 CMJ 会话。</div>;
    const representativeIndex = session.representative && session.representative.index;
    const trial = (session.trials || []).find(function (item) {
      return String(item.index) === String(representativeIndex);
    }) || (session.trials || [])[0];
    if (!trial || !trial.type) return <div className="frp-absent" key={moduleId}>代表 trial 未保存 CMJ 分型；不会重新分类。</div>;
    const structure = trial.isBimodal ? '双峰推进力曲线' : '单峰推进力曲线';
    const timing = trial.isLF1 ? '峰值力位于重心最低点附近（LF1）' : '峰值力位于重心最低点之后（LF2）';
    return (
      <div key={moduleId} data-frp-cmj-classification>
        <div className="sec-h"><span className="lbl">CMJ 分型 · 代表 trial · 保存时结果</span><span className="ln"></span></div>
        <div className="frp-classification-card">
          <div className="frp-classification-type">Type {trial.type}</div>
          <strong>{FRP_CMJ_TYPE_LABELS[trial.type] || structure}</strong>
          <span>{FRP_CMJ_TYPE_DESCRIPTIONS[trial.type] || (structure + ' · ' + timing)}</span>
          <small>{FRP_CMJ_CLASSIFICATION_DISCLAIMER}</small>
        </div>
      </div>
    );
  }

  if (moduleId === 'cmj-phase-visual') {
    const session = selection.cmj;
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 CMJ 会话。</div>;
    const defs = frpMetricDefs('cmj-metrics') || [];
    const total = frpRepresentativeMetricValue(session, 'cmj', 'ttt', defs);
    const phases = [
      { key: 'unweightingTime', label: '卸载' },
      { key: 'brakingTime', label: '制动' },
      { key: 'propulsiveTime', label: '推进' },
    ].map(function (item) {
      return Object.assign({}, item, { value: frpRepresentativeMetricValue(session, 'cmj', item.key, defs) });
    });
    const ready = total > 0 && phases.every(function (phase) { return phase.value != null && isFinite(phase.value); });
    if (!ready) return <div className="frp-absent" key={moduleId}>代表 trial 未保存完整阶段时长；不会重新检测。</div>;
    return (
      <div key={moduleId} data-frp-phase-visual>
        <div className="sec-h"><span className="lbl">CMJ 阶段结构 · 代表 trial · 保存时参数</span><span className="ln"></span></div>
        <div className="frp-phase-card">
          <div className="frp-phase-total">{frpFmt(total, 's')} s</div>
          <div className="frp-phase-track" aria-label="代表 trial 阶段时长比例">
            {phases.map(function (phase) { return <span key={phase.key} style={{ width: Math.max(2, phase.value / total * 100) + '%' }} />; })}
          </div>
          <div className="frp-phase-legend">
            {phases.map(function (phase) { return <span key={phase.key}>{phase.label} {frpFmt(phase.value, 's')}s · {Math.round(phase.value / total * 100)}%</span>; })}
          </div>
        </div>
      </div>
    );
  }

  if (moduleId === 'cmj-asymmetry-visual') {
    const session = selection.cmj;
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 CMJ 会话。</div>;
    const defs = frpMetricDefs('cmj-metrics') || [];
    const rows = [
      ['asymBraking', '制动力'], ['asymProp', '推进力'],
      ['asymBrakImpulse', '制动冲量'], ['asymPropImpulse', '推进冲量'],
    ].map(function (pair) {
      return { key: pair[0], label: pair[1], value: frpRepresentativeMetricValue(session, 'cmj', pair[0], defs) };
    }).filter(function (row) { return row.value != null && isFinite(row.value); });
    if (!rows.length) return <div className="frp-absent" key={moduleId}>代表 trial 未保存左右不对称参数；不会补算。</div>;
    return (
      <div key={moduleId} data-frp-asymmetry-visual>
        <div className="sec-h"><span className="lbl">CMJ 左右不对称 · 代表 trial · 保存时参数</span><span className="ln"></span></div>
        <div className="frp-asym-card">
          {rows.map(function (row) {
            const width = Math.min(50, Math.abs(row.value) / 20 * 50);
            return (
              <div className="frp-asym-row" key={row.key} data-metric-key={row.key}>
                <span>{row.label}</span>
                <span className="frp-asym-axis"><i className={'frp-asym-fill ' + (row.value < 0 ? 'right' : 'left')} style={{ width: width + '%' }} /></span>
                <span className="frp-asym-value">{row.value > 0 ? '+' : ''}{frpFmt(row.value, '%')}%</span>
              </div>
            );
          })}
        </div>
        <div className="frp-cmp-foot">正值 = 左侧主导 · 仅呈现保存值，不作诊断或自动处方。</div>
      </div>
    );
  }

  if (moduleId === 'cmj-curves' || moduleId === 'cmj-norm') {
    const session = selection.cmj;
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 CMJ 会话。</div>;
    const cmjLive = (window.__FORCE_TEST_INTERNALS__ && window.__FORCE_TEST_INTERNALS__.cmj) || {};
    const CMJChart = cmjLive.CMJChart, CMJNormChart = cmjLive.CMJNormChart, LoopChart = cmjLive.LoopChart;
    const model = ctx.cmjModel || Object.assign(buildForceCmjCurveModel(session), { source: 'compact' });
    const repLive = model.repLive, repClass = model.repClass, multi = model.multi;
    const reportTrialPositions = frpReportTrialPositions(multi, ctx.cmjTrialIndices);
    const reportTrialIndexList = Array.from(reportTrialPositions).map(function (position) {
      return multi && multi.jumps && multi.jumps[position] ? multi.jumps[position].index : null;
    }).filter(function (index) { return index != null; }).join(',');

    // ── FORCE-TRACE M3-B render matrix: a refs-bearing session whose trace is not READY never
    //    falls back to compact — the report prints an honest placeholder instead (loading /
    //    storage unavailable). Only legacy sessions (source 'compact') show the compact curves,
    //    ALWAYS under an explicit degraded label. ──
    if (model.source === 'pending') {
      const text = model.state === 'storage-error'
        ? '本地原始数据存储不可用 · 原图无法读取（不以压缩曲线替代）。'
        : '原始曲线加载中…（导出会等待加载完成）。';
      return <div className="frp-absent" key={moduleId}>{text}</div>;
    }
    const frpDegraded = model.source === 'compact'
      ? <div key="deg" style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, background: 'rgba(217,119,6,.12)', color: '#d97706', border: '1px solid rgba(217,119,6,.35)', marginBottom: 6 }}>
          降级显示：压缩曲线（200 点重建 · 非原始采样 · 无落地）— 该会话早于原图存储。
        </div>
      : null;
    const frpPartialNote = (model.source === 'trace' && multi && multi.partial)
      ? <div key="pn" style={{ fontSize: 10, color: 'var(--muted-2, var(--muted))', padding: '2px 2px 0' }}>部分 trial 原图缺失 · 仅显示可读取的 trial。</div>
      : null;

    if (moduleId === 'cmj-norm') {
      // 常模/归一化: CMJNormChart's data dependency is the reconstructed multi-
      // trial overlay (built from the session's own stored curves). No external
      // norm-dataset / knowledge props needed for THIS chart, so it is cleanly
      // reachable. (Population-percentile norm threading from app state is a
      // different feature — deferred; see report-back.)
      if (!CMJNormChart || !multi) return <div className="frp-absent" key="cmj-norm">该会话无可重建曲线，归一化叠加不可用。</div>;
      return (
        <div key="cmj-norm" data-frp-curve-module="cmj-norm" data-selected-count={reportTrialPositions.size} data-selected-trials={reportTrialIndexList}>
          <div className="sec-h"><span className="lbl">归一化力时序 · {reportTrialPositions.size} trial 叠加{model.source === 'trace' ? ' · 原始' : ''}</span><span className="ln"></span></div>
          {frpDegraded}{frpPartialNote}
          {reportTrialPositions.size ? (
            <div className="frp-chart-wrap">
              <CMJNormChart
                jumps={multi.jumps}
                compareSelected={reportTrialPositions}
                total={multi.total} left={multi.left} right={multi.right}
                bw_n={multi.bw_n} mass_kg={multi.mass_kg}
                overlays={{ vel: false, disp: false, acc: false, power: false }}
                classifications={null}
                showPhaseBg={true}
                phaseBasisLabel={multi.jumps[Array.from(reportTrialPositions)[0]] ? ('Jump ' + multi.jumps[Array.from(reportTrialPositions)[0]].index) : null}
                hideHoverHint={true}
              />
              {ctx.normKeyLegend ? (
                <div className="frp-keypoint-legend" data-frp-keypoint-legend>
                  {FRP_CMJ_KEYPOINTS_ZH.map(function (point) {
                    return <span key={point.key}><b>{point.key}</b> {point.name}：{point.meaning}</span>;
                  })}
                </div>
              ) : null}
            </div>
          ) : <div className="frp-absent">未选择可绘制的 trial。</div>}
        </div>
      );
    }

    // cmj-curves: F-t curve (representative trial) + F-D / F-V loops.
    if (!repLive) {
      // trace source but the REPRESENTATIVE trial is missing/corrupt → honest per-trial notice
      // (never a compact substitute); legacy compact with no reconstructable curve → old notice.
      const t = model.source === 'trace'
        ? '代表 trial 原图缺失或损坏，无法绘制原始曲线（不以压缩曲线替代）。'
        : '该会话无可重建的曲线数据。';
      return <div className="frp-absent" key="cmj-curves">{t}</div>;
    }
    return (
      <div key="cmj-curves" data-frp-curve-module="cmj-curves" data-selected-count={reportTrialPositions.size} data-selected-trials={reportTrialIndexList}>
        <div className="sec-h"><span className="lbl">{model.source === 'trace' ? 'CMJ 力学曲线 · 原始完整（含落地）' : 'CMJ 力学曲线 · 技术源真值'}</span><span className="ln"></span></div>
        {frpDegraded}{frpPartialNote}
        {CMJChart && (
          <div className="frp-chart-wrap">
            <CMJChart
              time={repLive.time}
              left={repLive.left} right={repLive.right} total={repLive.total}
              velAt={repLive.velAt} dispAt={repLive.dispAt}
              phases={repLive.phases}
              bw_n={repLive.bw_n} mass_kg={repLive.mass_kg}
              overlays={{ vel: true, disp: false, acc: false, power: false }}
              normalizeX={false}
              classResult={repClass}
              hideZoom={true}
            />
          </div>
        )}
        {LoopChart && multi && reportTrialPositions.size ? (
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
            <div className="frp-chart-wrap" style={{ margin: 0 }}>
              <LoopChart mode="fd" jumps={multi.jumps} compareSelected={reportTrialPositions} total={multi.total} bw_n={multi.bw_n} />
            </div>
            <div className="frp-chart-wrap" style={{ margin: 0 }}>
              <LoopChart mode="fv" jumps={multi.jumps} compareSelected={reportTrialPositions} total={multi.total} bw_n={multi.bw_n} />
            </div>
          </div>
        ) : (multi ? <div className="frp-absent">未选择可绘制的 trial · F-D / F-V 不显示。</div> : null)}
        <div style={{ fontSize: 10, color: 'var(--muted-2, var(--muted))', fontStyle: 'italic', padding: '4px 2px 0' }}>
          {model.source === 'trace'
            ? '注：原始完整曲线（含基线与落地）· 左右分量为真实采样。'
            : '注：仅显示 onset → takeoff 区间（保存时未保留基线与着陆）；左右分量未单独存储，以总力 50/50 占位。'}
        </div>
      </div>
    );
  }

  if (moduleId === 'cmj-metrics') {
    const session = selection.cmj;
    if (!session) return <div className="frp-absent" key="cmj-metrics">未选择 CMJ 会话。</div>;
    const allDefs = frpMetricDefs('cmj-metrics') || [];
    // FORCE-5a: metric multi-select + presentation mode (defaults: ALL · 表格).
    const selSet = frpSelectedKeySet(ctx.metricSel, 'cmj-metrics', allDefs);
    const defs = allDefs.filter(function (d) { return selSet.has(d.key); });
    if (!defs.length) return <div className="frp-absent" key="cmj-metrics">未选择指标 · 在检查器中勾选。</div>;
    const reference = frpReferenceForModule('cmj-metrics', ctx, allDefs);
    const percentileSort = !!(ctx.percentileSort && ctx.percentileSort['cmj-metrics']) && !!reference;
    const mode = (ctx.vizMode && ctx.vizMode['cmj-metrics']) || 'table';
    if (mode !== 'table') {
      const modeDef = FRP_VIZ_MODES.find(function (m) { return m.id === mode; });
      return (
        <div key="cmj-metrics">
          <div className="sec-h"><span className="lbl">CMJ 指标 · {modeDef ? modeDef.label : mode} · 全部会话时间升序 · 原始实测</span><span className="ln"></span></div>
          <ForceReferencePopulation reference={reference} />
          {renderForceMetricChartGrid('cmj-metrics', 'cmj', defs, mode, inputs, session, reference)}
        </div>
      );
    }
    const rows = defs.map(function (d) {
      const v = frp_getMetricValue(session, 'cmj', d.key);
      return { def: d, v: v };
    }).filter(function (r) { return r.v != null; });
    if (!rows.length) return <div className="frp-absent" key="cmj-metrics">该会话无可显示的 summary 指标。</div>;
    // Group by section, honoring def order.
    const groups = [];
    rows.forEach(function (r) {
      const sec = r.def.section || '指标';
      let g = groups.find(function (x) { return x.sec === sec; });
      if (!g) { g = { sec: sec, rows: [] }; groups.push(g); }
      g.rows.push(r);
    });
    if (percentileSort) groups.forEach(function (group) { group.rows = frpSortRowsByPercentile(group.rows, reference); });
    return (
      <div key="cmj-metrics">
        <div className="sec-h"><span className="lbl">CMJ 指标 · {frpDate(session.date)} · 原始实测</span><span className="ln"></span></div>
        <ForceReferencePopulation reference={reference} />
        {reference ? <div className={'frp-metric-row has-reference' + (percentileSort ? ' show-percentile' : '')}>
          <span></span><span className="frp-ref-head">本次</span><span className="frp-ref-head">{reference.label}</span><span className="frp-ref-head">差值</span>
          {percentileSort ? <span className="frp-ref-head">数值百分位</span> : null}
        </div> : null}
        {groups.map(function (g) {
          return (
            <div className="frp-metric-group" key={g.sec}>
              <div className="frp-metric-grp">{g.sec}</div>
              {g.rows.map(function (r) {
                return (
                  <div className={'frp-metric-row' + (reference ? ' has-reference' : '') + (percentileSort ? ' show-percentile' : '')} key={r.def.key} data-metric-key={r.def.key}>
                    <span>{frpTipLabel(r.def)}</span>
                    <span className="frp-mrv frp-metric-number">{frpFmt(r.v, r.def.unit)}{r.def.unit ? ' ' + r.def.unit : ''}</span>
                    {reference ? <ForceMetricReferenceCells value={r.v} definition={r.def} reference={reference} showPercentile={percentileSort} /> : null}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    );
  }

  // sj-metrics / imtp-metrics: mirror cmj-metrics exactly, generic over type.
  // Defs come from the window-exposed SJ_SUMMARY_METRICS / IMTP_SUMMARY_METRICS
  // (the same globals ForceSessionSource reads); values via the single
  // getMetricValue access path. Raw measured values only — no derivation. These
  // defs carry no `section`, so all rows fall under the '指标' group fallback.
  if (moduleId === 'sj-metrics' || moduleId === 'imtp-metrics') {
    const typeId = moduleId === 'sj-metrics' ? 'sj' : 'imtp';
    const label = typeId === 'sj' ? 'SJ' : 'IMTP';
    const session = selection[typeId];
    if (!session) return <div className="frp-absent" key={moduleId}>未选择 {label} 会话。</div>;
    const allDefs = frpMetricDefs(moduleId) || [];
    // FORCE-5a: metric multi-select + presentation mode (defaults: ALL · 表格).
    const selSet = frpSelectedKeySet(ctx.metricSel, moduleId, allDefs);
    const defs = allDefs.filter(function (d) { return selSet.has(d.key); });
    if (!defs.length) return <div className="frp-absent" key={moduleId}>未选择指标 · 在检查器中勾选。</div>;
    const reference = frpReferenceForModule(moduleId, ctx, allDefs);
    const mode = (ctx.vizMode && ctx.vizMode[moduleId]) || 'table';
    if (mode !== 'table') {
      const modeDef = FRP_VIZ_MODES.find(function (m) { return m.id === mode; });
      return (
        <div key={moduleId}>
          <div className="sec-h"><span className="lbl">{label} 指标 · {modeDef ? modeDef.label : mode} · 全部会话时间升序 · 原始实测</span><span className="ln"></span></div>
          <ForceReferencePopulation reference={reference} />
          {renderForceMetricChartGrid(moduleId, typeId, defs, mode, inputs, session, reference)}
        </div>
      );
    }
    const rows = defs.map(function (d) {
      const v = frp_getMetricValue(session, typeId, d.key);
      return { def: d, v: v };
    }).filter(function (r) { return r.v != null; });
    if (!rows.length) return <div className="frp-absent" key={moduleId}>该会话无可显示的 summary 指标。</div>;
    // Group by section, honoring def order.
    const groups = [];
    rows.forEach(function (r) {
      const sec = r.def.section || '指标';
      let g = groups.find(function (x) { return x.sec === sec; });
      if (!g) { g = { sec: sec, rows: [] }; groups.push(g); }
      g.rows.push(r);
    });
    const percentileSort = !!(ctx.percentileSort && ctx.percentileSort[moduleId]) && !!reference;
    if (percentileSort) groups.forEach(function (group) { group.rows = frpSortRowsByPercentile(group.rows, reference); });
    return (
      <div key={moduleId}>
        <div className="sec-h"><span className="lbl">{label} 指标 · {frpDate(session.date)} · 原始实测</span><span className="ln"></span></div>
        <ForceReferencePopulation reference={reference} />
        {reference ? <div className={'frp-metric-row has-reference' + (percentileSort ? ' show-percentile' : '')}>
          <span></span><span className="frp-ref-head">本次</span><span className="frp-ref-head">{reference.label}</span><span className="frp-ref-head">差值</span>
          {percentileSort ? <span className="frp-ref-head">数值百分位</span> : null}
        </div> : null}
        {groups.map(function (g) {
          return (
            <div className="frp-metric-group" key={g.sec}>
              <div className="frp-metric-grp">{g.sec}</div>
              {g.rows.map(function (r) {
                return (
                  <div className={'frp-metric-row' + (reference ? ' has-reference' : '') + (percentileSort ? ' show-percentile' : '')} key={r.def.key} data-metric-key={r.def.key}>
                    <span>{frpTipLabel(r.def)}</span>
                    <span className="frp-mrv frp-metric-number">{frpFmt(r.v, r.def.unit)}{r.def.unit ? ' ' + r.def.unit : ''}</span>
                    {reference ? <ForceMetricReferenceCells value={r.v} definition={r.def} reference={reference} showPercentile={percentileSort} /> : null}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    );
  }

  return null;
}

// ── ForceReportPrintBody (id='force-report-body') · paper rendering of the
//    ACTIVE tab's ENABLED modules. This node is both the canvas preview and the
//    clone source for print (window.doPrintBundle reuses the sanctioned
//    fitReportToOnePage + printing-report kernel). ──
function frpReportDraftScope(athlete, activeTab, selection) {
  if (!athlete || !athlete.id) return null;
  if (activeTab === 'overview') {
    const signature = ['cmj', 'sj', 'imtp'].map(function (typeId) {
      return selection && selection[typeId] && selection[typeId].id ? typeId + ':' + selection[typeId].id : typeId + ':none';
    }).join('§');
    return { athleteId: athlete.id, testType: 'overview', sessionId: signature };
  }
  const session = selection && selection[activeTab];
  return session && session.id ? { athleteId: athlete.id, testType: activeTab, sessionId: session.id } : null;
}

function frpAthleteIdentityParts(athlete) {
  const source = athlete || {};
  const demographics = window.AthleteProfile?.resolveDemographics
    ? window.AthleteProfile.resolveDemographics(source)
    : { age: source.age, gender: source.gender || source.sex || '', sport: source.sport || '' };
  const parts = [];
  if (source.jersey != null && String(source.jersey).trim()) {
    parts.push(`#${String(source.jersey).padStart(2, '0')}`);
  }
  if (source.position) parts.push(source.position);
  if (demographics.age != null) parts.push(`年龄 ${Number(demographics.age).toFixed(1)} 岁`);
  if (demographics.gender) parts.push(`性别 ${demographics.gender}`);
  if (demographics.sport) parts.push(`项目 ${demographics.sport}`);
  if (source.country) parts.push(source.country);
  return parts;
}

function frpLoadReportComment(repository, scope) {
  if (!repository || !scope || typeof repository.load !== 'function') return '';
  try { return repository.load(scope); } catch (_) { return ''; }
}

function frpReadLogoDataUrl(file) {
  return new Promise(function (resolve, reject) {
    const reader = new FileReader();
    reader.onload = function () { resolve(String(reader.result || '')); };
    reader.onerror = function () { reject(new Error('Logo 文件读取失败')); };
    reader.readAsDataURL(file);
  });
}

function frpVerifyLogoImage(dataUrl) {
  return new Promise(function (resolve, reject) {
    const image = new Image();
    image.onload = function () { resolve(true); };
    image.onerror = function () { reject(new Error('Logo 图像无法解码')); };
    image.src = dataUrl;
  });
}

function frpOrderedModules(modules, orderedIds) {
  const list = Array.isArray(modules) ? modules : [];
  const byId = new Map(list.map(function (module) { return [module.id, module]; }));
  const ordered = (Array.isArray(orderedIds) ? orderedIds : [])
    .map(function (id) { return byId.get(id); }).filter(Boolean);
  const seen = new Set(ordered.map(function (module) { return module.id; }));
  return ordered.concat(list.filter(function (module) { return !seen.has(module.id); }));
}

function ForceReportPrintBody({ athlete, activeTab, enabled, moduleOrder, selection: rawSelection, inputs: rawInputs, bodyId, metricSel, vizMode, profileSection, referenceConfig, percentileSort, normKeyLegend, normRecords, compareSel, roster, inputsForAthlete: rawInputsForAthlete, athleteCompareSel, curveMode, cmjTrialIndices, traceRead, traceReadModel, onCmjTraceState, onCmjReadyTrials, branding }) {
  const selection = frpEffectiveReportSelection(rawSelection);
  const inputs = frpEffectiveReportInputs(rawInputs);
  const inputsForAthlete = typeof rawInputsForAthlete === 'function'
    ? function (athleteId) { return frpEffectiveReportInputs(rawInputsForAthlete(athleteId)); }
    : null;
  const FRM = window.ForceReportModules || null;
  const mods = FRM ? FRM.modulesForTab(activeTab) : [];
  const activeMods = frpOrderedModules(mods, moduleOrder && moduleOrder[activeTab])
    .filter(function (m) { return enabled[m.id]; });
  const initials = (athlete && athlete.name ? athlete.name.split(' ').map(function (w) { return w[0]; }).join('').slice(0, 2).toUpperCase() : '—');
  const athleteIdentityParts = frpAthleteIdentityParts(athlete);

  // ── FORCE-TRACE M3-B: ONE curve truth for the report = the same ReadModel bundle the analysis
  //    face uses. Load identity = sessionId + full refs signature (sealed M3-A contract); a stale
  //    generation can never write back; the render-phase forKey guard blocks cross-frame reuse.
  //    availability='none' (legacy session) → the compact model, ALWAYS under a degraded label.
  //    A refs-bearing session NEVER silently falls back to compact — pending/storage-error render
  //    honest placeholders instead (also in the hidden bundle-staging path, where the accessor
  //    may be absent → 'storage-error' honesty, not compact).
  const cmjSession = (activeTab === 'cmj' && selection.cmj) ? selection.cmj : null;
  const cmjAvailability = (cmjSession && traceReadModel) ? traceReadModel.deriveAvailability(cmjSession)
    : (cmjSession && Array.isArray(cmjSession.traceRefs) && cmjSession.traceRefs.length ? 'unknown' : 'none');
  const frpRefsSig = (cmjSession && Array.isArray(cmjSession.traceRefs))
    ? cmjSession.traceRefs.map(function (r) { return r ? r.id + '@' + r.status + '@' + r.schemaVersion : 'x'; }).join('|') : '';
  const frpTraceKey = cmjSession ? cmjSession.id + '§' + frpRefsSig : null;
  const frpLoadState = React.useState({ state: 'idle', bundle: null, forKey: null });
  const frpLoad = frpLoadState[0], setFrpLoad = frpLoadState[1];
  const frpGen = React.useRef(0);
  React.useEffect(function () {
    const sid = cmjSession ? cmjSession.id : null;
    if (!sid || cmjAvailability === 'none') {
      frpGen.current++; setFrpLoad({ state: 'idle', bundle: null, forKey: frpTraceKey });
      return function () { frpGen.current++; };
    }
    if (!traceRead || !traceReadModel || typeof traceRead.listBySession !== 'function') {
      frpGen.current++; setFrpLoad({ state: 'storage-error', bundle: null, forKey: frpTraceKey });
      return function () { frpGen.current++; };
    }
    const gen = ++frpGen.current;
    setFrpLoad({ state: 'loading', bundle: null, forKey: frpTraceKey });
    Promise.resolve().then(function () { return traceRead.listBySession(sid); }).then(function (records) {
      if (frpGen.current !== gen) return;
      setFrpLoad({ state: 'ready', bundle: traceReadModel.buildTraceBundle(records, cmjSession), forKey: frpTraceKey });
    }).catch(function () {
      if (frpGen.current !== gen) return;
      setFrpLoad({ state: 'storage-error', bundle: null, forKey: frpTraceKey });
    });
    return function () { frpGen.current++; };
  }, [frpTraceKey, cmjAvailability]);
  const frpEffLoad = (frpLoad.forKey === frpTraceKey) ? frpLoad : { state: 'loading', bundle: null, forKey: frpTraceKey };
  const frpReadyTrialIndices = (frpEffLoad.state === 'ready' && frpEffLoad.bundle && cmjSession && Array.isArray(cmjSession.trials))
    ? cmjSession.trials.filter(function (trial) {
        const entry = frpEffLoad.bundle.get(trial.index);
        return !!(entry && entry.state === 'ready');
      }).map(function (trial) { return trial.index; })
    : [];
  const frpReadyTrialSig = frpReadyTrialIndices.join('|');
  React.useEffect(function () {
    // Reports {forKey, availability, state} — the parent verifies forKey against the key it
    // EXPECTS right now, so a stale/absent report can never read as ready (fail-closed contract).
    if (onCmjTraceState) onCmjTraceState({ forKey: frpTraceKey, availability: cmjAvailability, state: frpEffLoad.state });
    if (onCmjReadyTrials) onCmjReadyTrials({ forKey: frpTraceKey, state: frpEffLoad.state, readyTrialIndices: frpReadyTrialIndices });
  }, [frpTraceKey, cmjAvailability, frpEffLoad.state, frpReadyTrialSig]);
  // Readiness is bound to THIS COMPONENT INSTANCE, not just the session key (GPT M3-B final): a
  // long-lived parent (AppModals) must never reuse a previous instance's 'ready' after the staged
  // body unmounted — on unmount the report is CLEARED, so a remount for the same key starts
  // blocked until the new instance actually reports.
  React.useEffect(function () {
    return function () { if (onCmjTraceState) onCmjTraceState(null); };
  }, []);
  React.useEffect(function () {
    return function () { if (onCmjReadyTrials) onCmjReadyTrials(null); };
  }, []);
  const cmjModel = cmjSession
    ? (cmjAvailability === 'none'
        ? Object.assign(buildForceCmjCurveModel(cmjSession), { source: 'compact' })
        : (frpEffLoad.state === 'ready'
            ? Object.assign(frpBuildTraceCmjModel(frpEffLoad.bundle, cmjSession), { source: 'trace' })
            : { source: 'pending', state: frpEffLoad.state, repLive: null, repClass: null, multi: null }))
    : null;
  // FORCE-5a: metricSel/vizMode default to {} = all metrics · 表格. The report-page
  // bundle staging (app-modals.jsx) passes neither and stays on these defaults.
  // FORCE-5b: compareSel default {} = all compare modules render their default
  // session selection (latest 3). The report-page bundle staging (app-modals.jsx)
  // passes none, but the compare modules are defaultOn:false so they don't render
  // there anyway — the default is only reached if a compare module is enabled.
  // FORCE-5c: roster + inputsForAthlete + athleteCompareSel drive the 个体间对比
  // module. The report-page bundle staging (app-modals.jsx, protected) passes none;
  // athlete-compare is defaultOn:false so it never renders there anyway (an unthreaded
  // roster/accessor only surfaces as the honest '选择对比运动员' empty state).
  // FORCE-5d: curveMode default {} = 会话对比 (session mode). The report-page bundle
  // staging (app-modals.jsx, protected) passes none; curve-compare is defaultOn:false
  // so it never renders there anyway.
  const ctx = { athlete: athlete, selection: selection, inputs: inputs, cmjModel: cmjModel, cmjTrialIndices: cmjTrialIndices, metricSel: metricSel || {}, vizMode: vizMode || {}, profileSection: profileSection || {}, referenceConfig: referenceConfig || {}, percentileSort: percentileSort || {}, normKeyLegend: normKeyLegend !== false, normRecords: normRecords || [], compareSel: compareSel || {}, roster: roster || [], inputsForAthlete: inputsForAthlete || null, athleteCompareSel: athleteCompareSel || {}, curveMode: curveMode || {} };
  const tabLabel = FRM && FRM.tabById(activeTab) ? FRM.tabById(activeTab).label : activeTab;
  const reportDraftRepository = window.ForceReportDraftRepo || null;
  const setReportDraftVersion = React.useState(0)[1];
  React.useEffect(function () {
    if (!reportDraftRepository || typeof reportDraftRepository.subscribe !== 'function') return undefined;
    return reportDraftRepository.subscribe(function () { setReportDraftVersion(function (version) { return version + 1; }); });
  }, [reportDraftRepository]);
  const printableReportComment = String(frpLoadReportComment(reportDraftRepository, frpReportDraftScope(athlete, activeTab, selection)));

  // bodyId defaults to the sanctioned single-tab print anchor. The all-tab bundle
  // (workbench 全部 export + report-page 测力台报告 staging) stages one body per tab
  // with a distinct id so doPrintBundle can clone each as its own page (FORCE-4).
  return (
    <div id={bodyId || 'force-report-body'} className="rpt">
      <article className="paper">
        <div className="ph">
          <div className="p-brand">
            {branding?.showLogo && branding?.logoDataUrl ? <img className="p-brand-logo" src={branding.logoDataUrl} alt="机构 Logo" /> : null}
            <div>
            <h2>测力台报告</h2>
            <div className="ph-sub">技术源真值 · Force-Plate Report · {tabLabel}</div>
            </div>
          </div>
          <div className="ph-meta">
            <div><span className="k">TAB</span>{tabLabel}</div>
            <div><span className="k">MODULES</span>{activeMods.length}</div>
          </div>
        </div>

        <div className="athlete-strip">
          <div style={{ display: 'flex', alignItems: 'center' }}>
            <div className="avatar">{initials}</div>
            <div>
              <div className="name">{athlete ? athlete.name : '—'}</div>
              {athleteIdentityParts.length > 0
                ? <div className="meta-bits">{athleteIdentityParts.join(' · ')}</div>
                : null}
            </div>
          </div>
        </div>

        <div className="rpt-modules">
          {activeMods.length
            ? activeMods.map(function (m) { return renderForceModule(m.id, ctx); })
            : <div className="frp-absent">本选项卡未启用模块 · 在左侧模块库中开启。</div>}
        </div>

        {printableReportComment.trim() ? (
          <div className="frp-report-comment">
            <div className="sec-h"><span className="lbl">专业评语</span><span className="ln"></span></div>
            <div className="frp-report-comment-body">{printableReportComment.trim()}</div>
          </div>
        ) : null}

        <div className="pfoot">
          <span>Performance Dashboard · 测力台报告</span>
          <span>{tabLabel} · 技术源真值</span>
        </div>
      </article>
    </div>
  );
}

// ── ForceReportWorkbench · header (identity + role label) + tab row + three
//    panes (模块库 / 画布 / 检查器) + export foot. ──
function ForceReferenceCohortPicker({
  state, roster, currentAthlete, onChange, onApply, onClose,
  title, subtitle, sourceOptions, showExcludeCurrent,
}) {
  if (!state) return null;
  const pickerTitle = title || '选择队列标准';
  const pickerSubtitle = subtitle || '从花名册筛选并预览参照人群';
  const cohortSources = Array.isArray(sourceOptions) ? sourceOptions : [];
  const canExcludeCurrent = showExcludeCurrent !== false;
  const model = window.ForceReportReferenceModel;
  const filters = Object.assign({
    query: '', gender: '', sport: '', group: '', position: '',
    targetAge: '', ageBelow: 0, ageAbove: 0, excludeCurrent: true,
  }, state.filters || {});
  const visible = model.filterCohortAthletes(roster || [], filters, {
    currentAthleteId: currentAthlete && currentAthlete.id,
  });
  const selectedIds = Array.isArray(state.draftIds) ? state.draftIds : [];
  const selectedSet = new Set(selectedIds.map(String));
  const selectedAthletes = (roster || []).filter(function (item) { return selectedSet.has(String(item.id)); });
  const visibleIds = visible.map(function (item) { return item.id; });
  const allVisibleSelected = visibleIds.length > 0 && visibleIds.every(function (id) { return selectedSet.has(String(id)); });
  const facets = function (key) { return model.cohortFacetValues(roster || [], key); };
  const patchFilters = function (patch) {
    onChange(Object.assign({}, state, { filters: Object.assign({}, filters, patch) }));
  };
  const setDraftIds = function (ids) {
    onChange(Object.assign({}, state, { draftIds: ids }));
  };
  const toggleAthlete = function (id) {
    const has = selectedSet.has(String(id));
    setDraftIds(has
      ? selectedIds.filter(function (item) { return String(item) !== String(id); })
      : selectedIds.concat([id]));
  };
  const toggleVisible = function () {
    if (allVisibleSelected) {
      const visibleSet = new Set(visibleIds.map(String));
      setDraftIds(selectedIds.filter(function (id) { return !visibleSet.has(String(id)); }));
      return;
    }
    const next = selectedIds.slice();
    visibleIds.forEach(function (id) {
      if (!next.some(function (item) { return String(item) === String(id); })) next.push(id);
    });
    setDraftIds(next);
  };
  return (
    <div className="frp-ref-picker-overlay" role="presentation" onMouseDown={function (event) { if (event.target === event.currentTarget) onClose(); }}>
      <section className="frp-ref-picker" role="dialog" aria-modal="true" aria-label={pickerTitle} data-frp-reference-picker={state.moduleId}>
        <header className="frp-ref-picker-head">
          <div><b>{pickerTitle}</b><span>{pickerSubtitle}</span></div>
          <button type="button" onClick={onClose} aria-label={'关闭' + pickerTitle}>关闭</button>
        </header>
        <div className="frp-ref-picker-filters" data-ai-control="force-report-cohort-filters">
          <label>搜索
            <input value={filters.query} placeholder="姓名 / 号码 / 国家"
              onChange={function (event) { patchFilters({ query: event.target.value }); }} />
          </label>
          {[
            ['gender', '性别'], ['sport', '项目'], ['group', '组别'], ['position', '位置'],
          ].map(function (entry) {
            return <label key={entry[0]}>{entry[1]}
              <select data-frp-cohort-filter={entry[0]} value={filters[entry[0]]} onChange={function (event) {
                const patch = {}; patch[entry[0]] = event.target.value; patchFilters(patch);
              }}>
                <option value="">全部</option>
                {facets(entry[0]).map(function (value) { return <option key={value} value={value}>{value}</option>; })}
              </select>
            </label>;
          })}
        </div>
        <div className="frp-ref-picker-body">
          <div className="frp-ref-picker-list">
            <div className="frp-ref-picker-tools">
              <span>{visible.length} 名符合筛选</span>
              <button type="button" onClick={toggleVisible}>{allVisibleSelected ? '取消当前结果' : '全选当前结果'}</button>
              <button type="button" onClick={function () { setDraftIds([]); }}>清空全部</button>
            </div>
            {visible.length ? visible.map(function (item) {
              const age = model.athleteAge(item);
              const checked = selectedSet.has(String(item.id));
              return (
                <label className="frp-ref-athlete-row" key={item.id}>
                  <input type="checkbox" checked={checked} onChange={function () { toggleAthlete(item.id); }} />
                  <b>{item.name}{currentAthlete && String(item.id) === String(currentAthlete.id) ? '（本人）' : ''}</b>
                  <span>{age == null ? '年龄未填' : age.toFixed(1) + ' 岁'}</span>
                  <span>{item.gender || '性别未填'}</span>
                  <span>{item.sport || '项目未填'}</span>
                  <span>{item.position || item.group || '位置未填'}</span>
                </label>
              );
            }) : <div className="frp-ref-picker-empty">当前条件下没有可选运动员。</div>}
          </div>
          <aside className="frp-ref-picker-summary">
            {cohortSources.length ? (
              <React.Fragment>
                <h4>队列人群</h4>
                <p>直接载入锁定模板实际使用的参照人群。不同队列不会自动合并。</p>
                <div className="frp-ref-cohort-sources" data-frp-batch-cohort-sources>
                  {cohortSources.map(function (source) {
                    return (
                      <button type="button" className="frp-ref-cohort-source" key={source.id}
                        data-frp-batch-cohort-source={source.id}
                        onClick={function () { setDraftIds(source.athleteIds.slice()); }}>
                        <b>{source.label}</b>
                        <span>{source.athleteIds.length} 名 · 点击替换当前选择</span>
                      </button>
                    );
                  })}
                </div>
              </React.Fragment>
            ) : null}
            <h4>年龄范围</h4>
            <p>留空中心年龄时不限制年龄；填写后按中心年龄上下浮动筛选。</p>
            <div className="frp-ref-age-grid">
              <label>中心年龄
                <input data-frp-cohort-filter="targetAge" type="number" min="0" max="100" step="0.1" inputMode="decimal" value={filters.targetAge}
                  onChange={function (event) { patchFilters({ targetAge: event.target.value }); }} />
              </label>
              <label>下浮
                <input data-frp-cohort-filter="ageBelow" type="number" min="0" max="30" step="0.1" inputMode="decimal" value={filters.ageBelow}
                  onChange={function (event) { patchFilters({ ageBelow: event.target.value }); }} />
              </label>
              <label>上浮
                <input data-frp-cohort-filter="ageAbove" type="number" min="0" max="30" step="0.1" inputMode="decimal" value={filters.ageAbove}
                  onChange={function (event) { patchFilters({ ageAbove: event.target.value }); }} />
              </label>
            </div>
            {canExcludeCurrent ? (
              <label style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 12, color: '#657181', fontSize: 10.5 }}>
                <input type="checkbox" style={{ width: 'auto' }} checked={filters.excludeCurrent !== false}
                  onChange={function (event) { patchFilters({ excludeCurrent: event.target.checked }); }} />
                从参照人群排除当前运动员
              </label>
            ) : null}
            <h4>已选择 {selectedAthletes.length} 名</h4>
            <div className="frp-ref-selected">
              {selectedAthletes.slice(0, 30).map(function (item) {
                return <div key={item.id}><span>{item.name}</span><button type="button" onClick={function () { toggleAthlete(item.id); }}>移除</button></div>;
              })}
              {selectedAthletes.length > 30 ? <p>另有 {selectedAthletes.length - 30} 名已选择。</p> : null}
              {!selectedAthletes.length ? <p>尚未选择参照运动员。</p> : null}
            </div>
          </aside>
        </div>
        <footer className="frp-ref-picker-foot">
          <button type="button" onClick={onClose}>取消</button>
          <button type="button" className="primary" onClick={onApply} disabled={!selectedAthletes.length}>
            应用 {selectedAthletes.length} 名运动员
          </button>
        </footer>
      </section>
    </div>
  );
}

function ForceReportWorkbench({ athlete, inputs, onClose, initialTab, initialSelection, roster, inputsForAthlete: rawInputsForAthlete, traceRead, traceReadModel, forceTargetAdapter }) {
  const FRM = window.ForceReportModules;
  const FSS = window.ForceSessionSource;
  const safeInputs = frpEffectiveReportInputs(inputs || { cmj: [], sj: [], imtp: [] });
  const inputsForAthlete = typeof rawInputsForAthlete === 'function'
    ? function (athleteId) { return frpEffectiveReportInputs(rawInputsForAthlete(athleteId)); }
    : null;

  const tabs = FRM.availableTabs(safeInputs);
  // FORCE-4: real report-surface entries pass initialTab (e.g. 'cmj' from the
  // former CMJ-source-report link-outs). Honor it only if that tab is available
  // for this athlete's data; otherwise fall back to the first available tab.
  const initialTabValid = initialTab && tabs.some(function (t) { return t.id === initialTab; });
  const [activeTab, setActiveTab] = React.useState(
    initialTabValid ? initialTab : (tabs.length ? tabs[0].id : 'overview'));
  const [enabled, setEnabled] = React.useState(function () { return FRM.defaultEnabled(); });
  // Report-local, per-tab composition order. It controls the visible canvas and both export
  // paths, but deliberately does not alter session data or persist a user preference.
  const [moduleOrder, setModuleOrder] = React.useState(function () {
    const order = {};
    FRM.listTabs().forEach(function (tab) {
      order[tab.id] = FRM.modulesForTab(tab.id).map(function (module) { return module.id; });
    });
    return order;
  });
  // FORCE-4: initialSelection overrides the per-type default (各类型最新) so a
  // targeted session (the one the link-out referenced) is pre-selected.
  const [selection, setSelection] = React.useState(function () {
    const base = FSS.defaultSelection(safeInputs);
    if (initialSelection) {
      Object.keys(initialSelection).forEach(function (k) {
        if (initialSelection[k]) base[k] = frpEffectiveReportSession(initialSelection[k]);
      });
    }
    return base;
  });
  const selectionTargets = React.useMemo(function () {
    const targets = {};
    ['cmj', 'sj', 'imtp'].forEach(function (typeId) {
      const session = selection[typeId];
      if (!session || !forceTargetAdapter) return;
      try {
        targets[typeId] = forceTargetAdapter.captureEffective({
          athleteId: athlete && athlete.id, testType: typeId, session: session, sessionSource: FSS,
        }).ticket;
      } catch (_) { targets[typeId] = null; }
    });
    return targets;
  }, [selection, forceTargetAdapter, athlete && athlete.id]);
  const reportTargetBlockedFor = function (typeId) {
    const selectedSession = selection[typeId];
    if (!selectedSession) return false;
    const ticket = selectionTargets[typeId];
    if (!ticket || !forceTargetAdapter) return true;
    const live = FSS.listSessions(safeInputs, typeId).find(function (entry) {
      return String(entry.id) === String(ticket.sessionId);
    });
    return !live || !forceTargetAdapter.validateSession(ticket, {
      ...live.session, type: typeId, athleteId: athlete && athlete.id,
    }).ok;
  };
  const reportActiveTargetBlocked = activeTab !== 'overview' && reportTargetBlockedFor(activeTab);
  const reportAnyTargetBlocked = ['cmj', 'sj', 'imtp'].some(reportTargetBlockedFor);
  // Report-local, session-scoped curve layers. This deliberately does not live-sync the analysis
  // face's transient selector and never mutates session/trace storage. The same stable indices are
  // threaded to the visible canvas and hidden all-tab print body, so preview === export.
  const reportCmjSession = selection.cmj || null;
  const reportCmjTrials = reportCmjSession && Array.isArray(reportCmjSession.trials) ? reportCmjSession.trials : [];
  const reportCmjRefSig = reportCmjSession && Array.isArray(reportCmjSession.traceRefs)
    ? reportCmjSession.traceRefs.map(function (ref) { return ref ? ref.id + '@' + ref.status + '@' + ref.schemaVersion : 'x'; }).join('|') : '';
  const reportCmjTrialKey = reportCmjSession
    ? reportCmjSession.id + '§' + reportCmjRefSig + '§'
      + (reportCmjSession.representative ? reportCmjSession.representative.index : '') + '§'
      + reportCmjTrials.map(function (t) { return t.index; }).join('|') : '';
  const reportCmjTraceKey = reportCmjSession ? reportCmjSession.id + '§' + reportCmjRefSig : null;
  const reportCmjLegacy = !!reportCmjSession && (!Array.isArray(reportCmjSession.traceRefs) || !reportCmjSession.traceRefs.length);
  const reportRuntimeState = React.useState({ forKey: null, state: 'idle', readyTrialIndices: [] });
  const reportRuntime = reportRuntimeState[0], setReportRuntime = reportRuntimeState[1];
  const reportRuntimeMatches = !reportCmjLegacy && reportRuntime.forKey === reportCmjTraceKey;
  const reportRuntimeReady = reportRuntimeMatches && reportRuntime.state === 'ready';
  const reportRuntimeReadyKeys = new Set(reportRuntimeReady ? reportRuntime.readyTrialIndices.map(function (id) { return String(id); }) : []);
  const reportCmjDrawable = new Set(reportCmjTrials.filter(function (trial) {
    if (reportCmjLegacy) return !!(trial.curve && trial.curve.t && trial.curve.t.length);
    return reportRuntimeReadyKeys.has(String(trial.index));
  }).map(function (trial) { return trial.index; }));
  const reportCmjRepresentative = reportCmjSession && reportCmjSession.representative ? reportCmjSession.representative.index : (reportCmjTrials[0] && reportCmjTrials[0].index);
  const reportCmjDefault = reportCmjDrawable.has(reportCmjRepresentative)
    ? reportCmjRepresentative : ((reportCmjTrials.find(function (trial) { return reportCmjDrawable.has(trial.index); }) || {}).index);
  const reportTrialState = React.useState({ forKey: '', trialIndices: [] });
  const reportTrialChoice = reportTrialState[0], setReportTrialChoice = reportTrialState[1];
  const reportTrialIndices = reportTrialChoice.forKey === reportCmjTrialKey
    ? reportTrialChoice.trialIndices.filter(function (index) { return reportCmjDrawable.has(index); })
    : (reportCmjDefault == null ? [] : [reportCmjDefault]);
  React.useEffect(function () {
    setReportTrialChoice({ forKey: reportCmjTrialKey, trialIndices: reportCmjDefault == null ? [] : [reportCmjDefault] });
  }, [reportCmjTrialKey, reportCmjDefault]);
  const toggleReportTrial = function (trialIndex) {
    if (!reportCmjDrawable.has(trialIndex)) return;
    setReportTrialChoice(function (prev) {
      const current = prev.forKey === reportCmjTrialKey ? prev.trialIndices.slice() : (reportCmjDefault == null ? [] : [reportCmjDefault]);
      const has = current.some(function (index) { return String(index) === String(trialIndex); });
      return { forKey: reportCmjTrialKey, trialIndices: has ? current.filter(function (index) { return String(index) !== String(trialIndex); }) : current.concat([trialIndex]) };
    });
  };
  const reportDraftRepository = window.ForceReportDraftRepo || null;
  const reportDraftScope = frpReportDraftScope(athlete, activeTab, selection);
  const reportDraftScopeKey = reportDraftScope
    ? (reportDraftRepository ? reportDraftRepository.scopeKey(reportDraftScope)
      : [reportDraftScope.athleteId, reportDraftScope.testType, reportDraftScope.sessionId].join('|'))
    : '';
  const [reportCommentRecord, setReportCommentRecord] = React.useState(function () {
    return { forKey: reportDraftScopeKey, text: frpLoadReportComment(reportDraftRepository, reportDraftScope) };
  });
  const [reportCommentSave, setReportCommentSave] = React.useState({ forKey: reportDraftScopeKey, state: 'saved' });
  const effectiveReportComment = reportCommentRecord.forKey === reportDraftScopeKey
    ? reportCommentRecord.text : frpLoadReportComment(reportDraftRepository, reportDraftScope);
  const effectiveReportCommentState = reportCommentSave.forKey === reportDraftScopeKey ? reportCommentSave.state : 'saved';
  React.useEffect(function () {
    setReportCommentRecord({ forKey: reportDraftScopeKey, text: frpLoadReportComment(reportDraftRepository, reportDraftScope) });
    setReportCommentSave({ forKey: reportDraftScopeKey, state: 'saved' });
  }, [reportDraftRepository, reportDraftScopeKey]);

  const saveReportComment = function (text) {
    setReportCommentRecord({ forKey: reportDraftScopeKey, text: text });
    if (!reportDraftRepository || !reportDraftScope) {
      setReportCommentSave({ forKey: reportDraftScopeKey, state: 'unavailable' });
      return;
    }
    try {
      reportDraftRepository.save(reportDraftScope, text);
      setReportCommentSave({ forKey: reportDraftScopeKey, state: 'saved' });
    } catch (_) {
      setReportCommentSave({ forKey: reportDraftScopeKey, state: 'error' });
    }
  };
  const [selectedModule, setSelectedModule] = React.useState(null);
  // FORCE-5a: per-metrics-module config, session-scoped like `selection` (NOT
  // persisted). metricSel[moduleId] undefined = all metrics; vizMode[moduleId]
  // undefined = 'table' (表格).
  const [metricSel, setMetricSel] = React.useState({});
  const [vizMode, setVizMode] = React.useState({});
  const [profileSection, setProfileSection] = React.useState({});
  const [percentileSort, setPercentileSort] = React.useState({});
  const [normKeyLegend, setNormKeyLegend] = React.useState(true);
  const [referenceConfig, setReferenceConfig] = React.useState({});
  const [referencePicker, setReferencePicker] = React.useState(null);
  const [normRecords, setNormRecords] = React.useState([]);
  const brandingRepository = React.useMemo(function () {
    try { return window.ReportBrandingRepository ? window.ReportBrandingRepository.create() : null; }
    catch (_) { return null; }
  }, []);
  const [branding, setBranding] = React.useState(function () {
    return brandingRepository ? brandingRepository.load() : null;
  });
  const [brandingOn, setBrandingOn] = React.useState(true);
  const [brandingError, setBrandingError] = React.useState('');
  const [batchOpen, setBatchOpen] = React.useState(false);
  const [batchPicker, setBatchPicker] = React.useState(null);
  const [batchSelectedIds, setBatchSelectedIds] = React.useState(function () {
    return athlete && athlete.id ? [athlete.id] : [];
  });
  const [batchSnapshot, setBatchSnapshot] = React.useState(null);
  const [batchInputSnapshot, setBatchInputSnapshot] = React.useState({});
  const [batchExportMode, setBatchExportMode] = React.useState('team-pdf');
  const [batchExportState, setBatchExportState] = React.useState({ status:'idle', completed:0, total:0, error:'' });
  const batchTraceStates = React.useRef({});
  const setBatchTraceTick = React.useState(0)[1];
  React.useEffect(function () {
    let alive = true;
    const store = window.FieldDataStore;
    if (!store || typeof store.listNorms !== 'function') return function () { alive = false; };
    Promise.resolve(store.init('default')).then(function () { return store.listNorms(); }).then(function (records) {
      if (alive) setNormRecords(Array.isArray(records) ? records : []);
    }).catch(function () { if (alive) setNormRecords([]); });
    return function () { alive = false; };
  }, []);
  // Page-command seam for the assistant. It supplies structured filters;
  // this workbench resolves them with the same pure model as the visible controls and
  // opens the preview for an explicit human Apply action. It never simulates clicks and
  // never writes a report configuration behind the user's back.
  React.useEffect(function () {
    const handleCommand = function (event) {
      const detail = event && event.detail ? event.detail : {};
      if (detail.action === 'preview-age-grouped-cmj-report-batch') {
        const planner = window.ForceReportWorkflowPlanner;
        if (!planner || typeof planner.plan !== 'function') return;
        const eligibleAthleteIds = (roster || []).filter(function (item) {
          const athleteInputs = typeof inputsForAthlete === 'function'
            ? inputsForAthlete(item.id)
            : (athlete && String(item.id) === String(athlete.id) ? safeInputs : null);
          return athleteInputs && !!FSS.defaultSelection(athleteInputs).cmj;
        }).map(function (item) { return item.id; });
        const workflow = planner.plan({
          organization:detail.organization,
          athletes:roster || [],
          eligibleAthleteIds:eligibleAthleteIds,
          metricDefinitions:frpMetricDefs('cmj-metrics') || [],
          resolveAge:window.ForceReportReferenceModel && window.ForceReportReferenceModel.athleteAge,
        });
        const required = new Set(workflow.moduleIds);
        setActiveTab('cmj');
        setSelectedModule('cmj-metric-profile');
        setEnabled(function (prev) {
          const next = Object.assign({}, prev);
          FRM.modulesForTab('cmj').forEach(function (module) { next[module.id] = required.has(module.id); });
          return next;
        });
        setModuleOrder(function (prev) {
          const next = Object.assign({}, prev);
          const remainder = FRM.modulesForTab('cmj').map(function (module) { return module.id; })
            .filter(function (id) { return !required.has(id); });
          next.cmj = workflow.moduleIds.concat(remainder);
          return next;
        });
        setMetricSel(function (prev) { return Object.assign({}, prev, workflow.metricSelection); });
        setVizMode(function (prev) { return Object.assign({}, prev, workflow.visualization); });
        setProfileSection(function (prev) { return Object.assign({}, prev, workflow.profileSections); });
        setReferenceConfig(function (prev) {
          const next = Object.assign({}, prev);
          workflow.referenceModuleIds.forEach(function (moduleId) {
            next[moduleId] = {
              mode:'cohort',
              athleteIds:(workflow.cohortAthleteIdsByTarget[athlete && athlete.id] || []).slice(),
              cohortAthleteIdsByTarget:workflow.cohortAthleteIdsByTarget,
              cohortFilters:{ ageGrouping:workflow.ageGrouping.mode, organization:workflow.organization },
              manualValues:{},
            };
          });
          return next;
        });
        setBatchSelectedIds(workflow.athleteIds.slice());
        setBatchPicker(null);
        setBatchSnapshot(null);
        setBatchOpen(true);
        document.dispatchEvent(new CustomEvent('axis:force-report-command-result', {
          detail:{
            action:detail.action,
            matchedCount:workflow.athleteIds.length,
            status:workflow.status,
            organization:workflow.organization,
            groups:workflow.groups,
            warnings:workflow.warnings,
          },
        }));
        return;
      }
      if (detail.action !== 'preview-cohort-reference') return;
      const moduleId = String(detail.moduleId || '');
      const meta = FRP_METRIC_MODULES[moduleId] || FRP_PROFILE_MODULES[moduleId];
      if (!meta || !window.ForceReportReferenceModel) return;
      const filters = Object.assign({
        query: '', gender: '', sport: '', group: '', position: '',
        targetAge: '', ageBelow: 0, ageAbove: 0, excludeCurrent: true,
      }, detail.filters || {});
      const existing = referenceConfig[moduleId] || {};
      const matches = window.ForceReportReferenceModel.filterCohortAthletes(roster || [], filters, {
        currentAthleteId: athlete && athlete.id,
      });
      setActiveTab(meta.typeId);
      setSelectedModule(moduleId);
      setEnabled(function (prev) { const next = Object.assign({}, prev); next[moduleId] = true; return next; });
      setReferencePicker({
        moduleId: moduleId,
        filters: filters,
        draftIds: detail.selectMatching === true
          ? matches.map(function (item) { return item.id; })
          : (Array.isArray(existing.athleteIds) ? existing.athleteIds.slice() : []),
      });
      document.dispatchEvent(new CustomEvent('axis:force-report-command-result', {
        detail: { action: detail.action, moduleId: moduleId, matchedCount: matches.length, status: 'preview-opened' },
      }));
    };
    document.addEventListener('axis:force-report-command', handleCommand);
    return function () { document.removeEventListener('axis:force-report-command', handleCommand); };
  }, [referenceConfig, roster, athlete && athlete.id, safeInputs, inputsForAthlete]);
  // FORCE-5b: per-compare-module session selection, session-scoped (NOT persisted).
  // compareSel[moduleId] undefined = default (latest 3); an explicit array = those
  // session ids (capped at FRP_COMPARE_MAX by the toggle).
  const [compareSel, setCompareSel] = React.useState({});
  // FORCE-5c: per-athlete-compare-module comparison-athlete selection, session-scoped
  // (NOT persisted). athleteCompareSel[moduleId] undefined = NONE (manual add); an
  // explicit array = those athlete ids (capped at FRP_ATHLETE_COMPARE_MAX by the toggle).
  const [athleteCompareSel, setAthleteCompareSel] = React.useState({});
  // FORCE-5d: per-curve-compare-module presentation mode ('session' 会话对比 default /
  // 'athlete' 运动员对比). This is a MODE flag only — the actual selection reuses the
  // 5b compareSel / 5c athleteCompareSel maps (NOT a new selection system).
  const [curveMode, setCurveMode] = React.useState({});
  const forceTemplateRepository = React.useMemo(function () {
    try { return window.ForceReportTemplateRepository ? window.ForceReportTemplateRepository.create() : null; }
    catch (_) { return null; }
  }, []);
  const [forceTemplates, setForceTemplates] = React.useState(function () {
    return forceTemplateRepository ? forceTemplateRepository.list() : [];
  });
  const [activeForceTemplateId, setActiveForceTemplateId] = React.useState(null);
  const cloneReportConfig = function (value) {
    return JSON.parse(JSON.stringify(value == null ? {} : value));
  };
  const reportTrialSlots = reportCmjTrials.reduce(function (slots, trial, position) {
    if (reportTrialIndices.some(function (index) { return String(index) === String(trial.index); })) slots.push(position);
    return slots;
  }, []);
  const trialIndicesForSlots = function (session, slots) {
    const trials = session && Array.isArray(session.trials) ? session.trials : [];
    const requested = Array.isArray(slots) ? slots : [];
    return requested.map(function (slot) {
      return Number.isInteger(slot) && trials[slot] ? trials[slot].index : null;
    }).filter(function (index) { return index != null; });
  };
  const representativeTrialIndices = function (session) {
    const trials = session && Array.isArray(session.trials) ? session.trials : [];
    const representativeIndex = session && session.representative ? session.representative.index : null;
    const representative = trials.find(function (trial) {
      return trial && String(trial.index) === String(representativeIndex);
    });
    const fallback = representative || trials[0] || null;
    return fallback ? [fallback.index] : [];
  };
  const captureForceTemplateConfig = function () {
    return {
      version: 1,
      activeTab: activeTab,
      enabled: cloneReportConfig(enabled),
      moduleOrder: cloneReportConfig(moduleOrder),
      metricSel: cloneReportConfig(metricSel),
      vizMode: cloneReportConfig(vizMode),
      profileSection: cloneReportConfig(profileSection),
      percentileSort: cloneReportConfig(percentileSort),
      normKeyLegend: normKeyLegend,
      referenceConfig: cloneReportConfig(referenceConfig),
      compareSel: cloneReportConfig(compareSel),
      athleteCompareSel: cloneReportConfig(athleteCompareSel),
      curveMode: cloneReportConfig(curveMode),
      brandingOn: brandingOn,
      cmjTrialMode: reportTrialIndices.length === 1
        && String(reportTrialIndices[0]) === String(reportCmjRepresentative)
        ? 'representative' : 'slots',
      cmjTrialSlots: reportTrialSlots.slice(),
    };
  };
  const applyForceTemplateConfig = function (config) {
    if (!config || typeof config !== 'object') return;
    if (config.activeTab && tabs.some(function (tab) { return tab.id === config.activeTab; })) setActiveTab(config.activeTab);
    if (config.enabled) setEnabled(cloneReportConfig(config.enabled));
    if (config.moduleOrder) setModuleOrder(cloneReportConfig(config.moduleOrder));
    if (config.metricSel) setMetricSel(cloneReportConfig(config.metricSel));
    if (config.vizMode) setVizMode(cloneReportConfig(config.vizMode));
    if (config.profileSection) setProfileSection(cloneReportConfig(config.profileSection));
    if (config.percentileSort) setPercentileSort(cloneReportConfig(config.percentileSort));
    if (typeof config.normKeyLegend === 'boolean') setNormKeyLegend(config.normKeyLegend);
    if (config.referenceConfig) setReferenceConfig(cloneReportConfig(config.referenceConfig));
    if (config.compareSel) setCompareSel(cloneReportConfig(config.compareSel));
    if (config.athleteCompareSel) setAthleteCompareSel(cloneReportConfig(config.athleteCompareSel));
    if (config.curveMode) setCurveMode(cloneReportConfig(config.curveMode));
    if (typeof config.brandingOn === 'boolean') setBrandingOn(config.brandingOn);
    if (Array.isArray(config.cmjTrialSlots)) {
      setReportTrialChoice({
        forKey: reportCmjTrialKey,
        trialIndices: config.cmjTrialMode === 'representative'
          ? representativeTrialIndices(reportCmjSession)
          : trialIndicesForSlots(reportCmjSession, config.cmjTrialSlots),
      });
    }
    setSelectedModule(null);
  };
  const saveForceTemplate = function () {
    const fallback = '测力台模板 ' + (forceTemplates.length + 1);
    const name = (window.prompt('模板名称', fallback) || '').trim();
    if (!name) return;
    const template = {
      id: 'force_tpl_' + Date.now(),
      name: name,
      createdAt: new Date().toISOString(),
      config: captureForceTemplateConfig(),
    };
    try {
      if (!forceTemplateRepository) throw new Error('Template repository unavailable');
      setForceTemplates(forceTemplateRepository.save(template));
      setActiveForceTemplateId(template.id);
    } catch (_) {}
  };
  const applyForceTemplate = function (template) {
    if (!template) return;
    applyForceTemplateConfig(template.config);
    setActiveForceTemplateId(template.id);
  };
  const deleteForceTemplate = function (templateId) {
    try {
      if (!forceTemplateRepository) return;
      setForceTemplates(forceTemplateRepository.remove(templateId));
    } catch (_) { return; }
    if (activeForceTemplateId === templateId) setActiveForceTemplateId(null);
  };
  // Roster minus the current athlete — the comparison-candidate pool (chips).
  const compareRoster = (Array.isArray(roster) ? roster : []).filter(function (a) {
    return !athlete || String(a.id) !== String(athlete.id);
  });
  const batchEligibleRoster = (Array.isArray(roster) ? roster : []).filter(function (item) {
    if (!batchSnapshot || batchSnapshot.activeTab === 'overview') return true;
    const itemInputs = typeof inputsForAthlete === 'function' ? inputsForAthlete(item.id) : null;
    const itemSelection = itemInputs ? FSS.defaultSelection(itemInputs) : {};
    return !!itemSelection[batchSnapshot.activeTab];
  });
  const batchCohortSources = (function () {
    if (!batchSnapshot || !batchSnapshot.referenceConfig) return [];
    const eligibleIds = new Set(batchEligibleRoster.map(function (item) { return String(item.id); }));
    const groups = {};
    const moduleLabel = function (moduleId) {
      const module = FRM.listTabs().reduce(function (found, tab) {
        return found || FRM.modulesForTab(tab.id).find(function (item) { return item.id === moduleId; });
      }, null);
      return module ? module.label : moduleId;
    };
    Object.keys(batchSnapshot.referenceConfig).forEach(function (moduleId) {
      const config = batchSnapshot.referenceConfig[moduleId];
      if (!batchSnapshot.enabled[moduleId] || !config || config.mode !== 'cohort' || !Array.isArray(config.athleteIds)) return;
      const ids = config.athleteIds.filter(function (id) { return eligibleIds.has(String(id)); });
      if (!ids.length) return;
      const fingerprint = ids.map(String).sort().join('|');
      if (!groups[fingerprint]) groups[fingerprint] = { athleteIds: ids, moduleLabels: [] };
      groups[fingerprint].moduleLabels.push(moduleLabel(moduleId));
    });
    return Object.keys(groups).map(function (fingerprint, index) {
      const group = groups[fingerprint];
      return {
        id: 'cohort-' + (index + 1),
        athleteIds: group.athleteIds,
        label: group.moduleLabels.join(' + '),
      };
    });
  })();
  const batchAthletes = (Array.isArray(roster) ? roster : []).filter(function (item) {
    return batchSelectedIds.some(function (id) { return String(id) === String(item.id); });
  });

  // Keep the active tab valid if the available set shifts (athlete/data change).
  React.useEffect(function () {
    if (!tabs.some(function (t) { return t.id === activeTab; })) {
      setActiveTab(tabs.length ? tabs[0].id : 'overview');
    }
  }, [tabs.map(function (t) { return t.id; }).join(','), activeTab]);

  const activeMods = frpOrderedModules(FRM.modulesForTab(activeTab), moduleOrder[activeTab]);
  const enabledActiveMods = activeMods.filter(function (module) { return !!enabled[module.id]; });

  const toggleModule = function (id) {
    setEnabled(function (prev) { const n = Object.assign({}, prev); n[id] = !n[id]; return n; });
  };

  const moveReportModule = function (moduleId, delta) {
    setModuleOrder(function (prev) {
      const orderedIds = frpOrderedModules(FRM.modulesForTab(activeTab), prev[activeTab])
        .map(function (module) { return module.id; });
      const enabledIds = orderedIds.filter(function (id) { return !!enabled[id]; });
      const enabledIndex = enabledIds.indexOf(moduleId);
      const targetEnabledId = enabledIds[enabledIndex + delta];
      if (enabledIndex < 0 || targetEnabledId == null) return prev;
      const from = orderedIds.indexOf(moduleId);
      const to = orderedIds.indexOf(targetEnabledId);
      const nextIds = orderedIds.slice();
      nextIds[from] = targetEnabledId;
      nextIds[to] = moduleId;
      const next = Object.assign({}, prev);
      next[activeTab] = nextIds;
      return next;
    });
  };

  // FORCE-5a handlers · metric chips + presentation mode for the selected module.
  const setModuleViz = function (moduleId, modeId) {
    setVizMode(function (prev) { const n = Object.assign({}, prev); n[moduleId] = modeId; return n; });
  };
  const toggleModuleProfileSection = function (moduleId, section, profiles) {
    setProfileSection(function (prev) {
      const current = frpSelectedProfileSections(profiles, prev[moduleId]);
      const has = current.indexOf(section) >= 0;
      const next = Object.assign({}, prev);
      next[moduleId] = has
        ? current.filter(function (item) { return item !== section; })
        : current.concat([section]);
      return next;
    });
  };
  const toggleMetricKey = function (moduleId, key) {
    setMetricSel(function (prev) {
      const defs = frpMetricDefs(moduleId) || [];
      const cur = Array.isArray(prev[moduleId]) ? prev[moduleId] : defs.map(function (d) { return d.key; });
      const next = cur.indexOf(key) > -1 ? cur.filter(function (k) { return k !== key; }) : cur.concat([key]);
      const n = Object.assign({}, prev); n[moduleId] = next; return n;
    });
  };
  const setAllMetricKeys = function (moduleId, all) {
    setMetricSel(function (prev) {
      const defs = frpMetricDefs(moduleId) || [];
      const n = Object.assign({}, prev);
      n[moduleId] = all ? defs.map(function (d) { return d.key; }) : [];
      return n;
    });
  };
  const setMetricSection = function (moduleId, section, on) {
    setMetricSel(function (prev) {
      const defs = frpMetricDefs(moduleId) || [];
      const current = frpSelectedKeySet(prev, moduleId, defs);
      defs.filter(function (definition) {
        return String(definition.section || '指标') === String(section);
      }).forEach(function (definition) {
        if (on) current.add(definition.key); else current.delete(definition.key);
      });
      const next = Object.assign({}, prev);
      next[moduleId] = defs.map(function (definition) { return definition.key; })
        .filter(function (key) { return current.has(key); });
      return next;
    });
  };
  const updateReferenceConfig = function (moduleId, patch) {
    setReferenceConfig(function (prev) {
      const next = Object.assign({}, prev);
      next[moduleId] = Object.assign({ mode: 'none', athleteIds: [], manualValues: {} }, prev[moduleId] || {}, patch || {});
      return next;
    });
  };
  const openReferencePicker = function (moduleId) {
    const config = referenceConfig[moduleId] || {};
    setReferencePicker({
      moduleId: moduleId,
      filters: Object.assign({
        query: '', gender: '', sport: '', group: '', position: '',
        targetAge: '', ageBelow: 0, ageAbove: 0, excludeCurrent: true,
      }, config.cohortFilters || {}),
      draftIds: Array.isArray(config.athleteIds) ? config.athleteIds.slice() : [],
    });
  };
  const applyReferencePicker = function () {
    if (!referencePicker) return;
    updateReferenceConfig(referencePicker.moduleId, {
      mode: 'cohort',
      athleteIds: referencePicker.draftIds.slice(),
      cohortFilters: Object.assign({}, referencePicker.filters),
    });
    setReferencePicker(null);
  };
  const handleBrandingFile = async function (event) {
    const file = event.target.files && event.target.files[0];
    event.target.value = '';
    if (!file || !brandingRepository || !window.ReportBatchPolicy) return;
    setBrandingError('');
    try {
      const bytes = new Uint8Array(await file.arrayBuffer());
      const checked = window.ReportBatchPolicy.validateLogo({ name: file.name, type: file.type, size: file.size, bytes: bytes });
      if (!checked.ok) {
        const message = { 'unsupported-type': '仅支持 PNG、JPEG 或 WebP。', 'too-large': 'Logo 文件不得超过 1 MB。', 'mime-mismatch': 'Logo 文件内容与扩展类型不一致。', empty: 'Logo 文件为空。' };
        throw new Error(message[checked.reason] || 'Logo 文件无效。');
      }
      const dataUrl = await frpReadLogoDataUrl(file);
      await frpVerifyLogoImage(dataUrl);
      setBranding(brandingRepository.save({ logoDataUrl: dataUrl, fileName: checked.name, mimeType: checked.mimeType, byteSize: checked.byteSize }));
      setBrandingOn(true);
    } catch (error) {
      setBrandingError(error && error.message ? error.message : 'Logo 上传失败。');
    }
  };
  const removeBranding = function () {
    if (!brandingRepository) return;
    try { brandingRepository.remove(); setBranding(null); setBrandingError(''); }
    catch (_) { setBrandingError('Logo 删除失败。'); }
  };

  // FORCE-5b handler · toggle a session id in a compare module's selection. Adding
  // is refused past FRP_COMPARE_MAX (print-width cap — inspector shows the cap).
  const toggleCompareSession = function (moduleId, sessionId, sessionList) {
    setCompareSel(function (prev) {
      const cur = Array.isArray(prev[moduleId]) ? prev[moduleId] : frpCompareDefaultIds(sessionList);
      const has = cur.some(function (id) { return String(id) === String(sessionId); });
      if (!has && cur.length >= FRP_COMPARE_MAX) return prev; // cap — refuse the 6th
      const next = has
        ? cur.filter(function (id) { return String(id) !== String(sessionId); })
        : cur.concat([sessionId]);
      const n = Object.assign({}, prev); n[moduleId] = next; return n;
    });
  };

  // FORCE-5c handler · toggle a comparison athlete in an athlete-compare module's
  // selection. Default is NONE (manual add). Adding is refused past
  // FRP_ATHLETE_COMPARE_MAX (print-width cap — inspector disables chips + hints).
  const toggleCompareAthlete = function (moduleId, athleteId) {
    setAthleteCompareSel(function (prev) {
      const cur = Array.isArray(prev[moduleId]) ? prev[moduleId] : [];
      const has = cur.some(function (id) { return String(id) === String(athleteId); });
      if (!has && cur.length >= FRP_ATHLETE_COMPARE_MAX) return prev; // cap — refuse the 5th
      const next = has
        ? cur.filter(function (id) { return String(id) !== String(athleteId); })
        : cur.concat([athleteId]);
      const n = Object.assign({}, prev); n[moduleId] = next; return n;
    });
  };

  // FORCE-5d handler · set a curve-compare module's presentation mode (会话/运动员).
  const setModuleCurveMode = function (moduleId, m) {
    setCurveMode(function (prev) { const n = Object.assign({}, prev); n[moduleId] = m; return n; });
  };

  // Session selectors: one per test-type WITH sessions. Changing one reselects
  // that type's session and recomputes everything that reads `selection`
  // (derived cards + the CMJ tab's charts/metrics).
  const types = FSS.listTestTypes();
  const selectableTypes = types.map(function (t) {
    return { t: t, list: FSS.listSessions(safeInputs, t.id) };
  }).filter(function (r) { return r.list.length; });

  const onSelectSession = function (typeId, sessionId) {
    const list = FSS.listSessions(safeInputs, typeId);
    const found = list.find(function (r) { return String(r.id) === String(sessionId); });
    setSelection(function (prev) { const n = Object.assign({}, prev); n[typeId] = found ? found.session : null; return n; });
  };

  // FORCE-TRACE M3-B: the canvas PrintBody reports its CMJ trace state up; export WAITS for the
  // raw curves — a print fired mid-load would clone a loading placeholder into the paper. Only
  // 'loading' blocks; 'storage-error' prints its honest placeholder (the truth), and legacy
  // sessions ('none') never load anything.
  // ── FORCE-TRACE M3-B fail-closed export contract (GPT audit): the parent SYNCHRONOUSLY
  // computes the key it EXPECTS (sessionId§refsSig from the CURRENT selection) and blocks export
  // unless the body has reported THAT key as ready. No report yet (first commit, before any
  // effect), a stale forKey, idle or loading → all BLOCK. 'storage-error' unblocks — the honest
  // placeholder IS the truth being printed. Legacy sessions (availability 'none') load nothing. ──
  const cmjTraceStates = React.useRef({});
  const setTraceStateTick = React.useState(0)[1];
  const noteCmjTraceState = function (id) {
    return function (s) { cmjTraceStates.current[id] = s; setTraceStateTick(function (t) { return t + 1; }); };
  };
  const noteCanvasReadyTrials = function (s) {
    setReportRuntime(s
      ? { forKey: s.forKey, state: s.state, readyTrialIndices: Array.isArray(s.readyTrialIndices) ? s.readyTrialIndices : [] }
      : { forKey: null, state: 'idle', readyTrialIndices: [] });
  };
  const frpSelCmjAvailability = (selection.cmj && traceReadModel) ? traceReadModel.deriveAvailability(selection.cmj)
    : (selection.cmj && Array.isArray(selection.cmj.traceRefs) && selection.cmj.traceRefs.length ? 'unknown' : 'none');
  const frpSelCmjKey = (function () {
    const s = selection.cmj;
    if (!s) return null;
    const sig = Array.isArray(s.traceRefs)
      ? s.traceRefs.map(function (r) { return r ? r.id + '@' + r.status + '@' + r.schemaVersion : 'x'; }).join('|') : '';
    return s.id + '§' + sig;
  })();
  // Blocked when the given body renders cmj content that has not been reported READY (or
  // storage-error) FOR THE CURRENTLY EXPECTED KEY.
  const frpBodyBlocked = function (bodyKeyId, bodyShowsCmj) {
    if (!bodyShowsCmj || !frpSelCmjKey) return false;      // no cmj content in that body
    if (frpSelCmjAvailability === 'none') return false;     // legacy — nothing loads
    const st = cmjTraceStates.current[bodyKeyId];
    if (!st) return true;                                   // never reported → fail-closed
    if (st.forKey !== frpSelCmjKey) return true;            // stale report for another key
    return st.state !== 'ready' && st.state !== 'storage-error';
  };
  const cmjCanvasBlocked = frpBodyBlocked('canvas', activeTab === 'cmj');
  const cmjAllBlocked = frpBodyBlocked('all-cmj', tabs.some(function (t) { return t.id === 'cmj'; }));
  const openBatchPicker = function (selectedIds) {
    setBatchPicker({
      moduleId: 'force-batch',
      filters: {
        query: '', gender: '', sport: '', group: '', position: '',
        targetAge: '', ageBelow: 0, ageAbove: 0, excludeCurrent: false,
      },
      draftIds: Array.isArray(selectedIds) ? selectedIds.slice() : batchSelectedIds.slice(),
    });
  };
  const applyBatchPicker = function () {
    if (!batchPicker) return;
    const eligibleIds = new Set(batchEligibleRoster.map(function (item) { return String(item.id); }));
    const selectedIds = batchPicker.draftIds.filter(function (id) { return eligibleIds.has(String(id)); });
    const frozenInputs = {};
    selectedIds.forEach(function (id) {
      const currentInputs = typeof inputsForAthlete === 'function' ? inputsForAthlete(id) : { cmj: [], sj: [], imtp: [] };
      const frozenSelection = FSS.defaultSelection(currentInputs);
      const targets = {};
      ['cmj', 'sj', 'imtp'].forEach(function (typeId) {
        const session = frozenSelection[typeId];
        if (!session || !forceTargetAdapter) return;
        try {
          targets[typeId] = forceTargetAdapter.captureEffective({
            athleteId: id, testType: typeId, session: session, sessionSource: FSS,
          }).ticket;
        } catch (_) { targets[typeId] = null; }
      });
      frozenInputs[String(id)] = {
        inputs: cloneReportConfig(currentInputs),
        selection: cloneReportConfig(frozenSelection),
        targets: cloneReportConfig(targets),
      };
    });
    setBatchSelectedIds(selectedIds);
    setBatchInputSnapshot(frozenInputs);
    setBatchPicker(null);
  };
  const lockBatchTemplate = function () {
    const snapshot = Object.assign({}, captureForceTemplateConfig(), {
      branding: branding ? Object.assign({}, branding, { showLogo: brandingOn }) : null,
    });
    setBatchSnapshot(snapshot);
    setBatchExportState({ status:'idle', completed:0, total:0, error:'' });
    setBatchInputSnapshot({});
    batchTraceStates.current = {};
    setBatchOpen(true);
    openBatchPicker(batchSelectedIds);
  };
  const batchContexts = batchSnapshot ? batchAthletes.map(function (item) {
    const frozen = batchInputSnapshot[String(item.id)] || null;
    const athleteInputs = frozen ? frozen.inputs : { cmj: [], sj: [], imtp: [] };
    const athleteSelection = frozen ? frozen.selection : {};
    const available = batchSnapshot.activeTab === 'overview' || !!athleteSelection[batchSnapshot.activeTab];
    const cmjTrialIndices = representativeTrialIndices(athleteSelection.cmj);
    return { athlete: item, inputs: athleteInputs, selection: athleteSelection, targets: frozen ? frozen.targets : {}, cmjTrialIndices: cmjTrialIndices, available: available };
  }).filter(function (context) { return context.available; }) : [];
  const batchTargetBlocked = !!batchSnapshot && batchSnapshot.activeTab !== 'overview' && batchContexts.some(function (context) {
    if (!forceTargetAdapter) return true;
    const type = batchSnapshot.activeTab;
    const frozenSession = context.selection[type];
    const ticket = context.targets[type];
    const liveInputs = typeof inputsForAthlete === 'function' ? inputsForAthlete(context.athlete.id) : null;
    const liveSession = liveInputs && ticket ? (FSS.listSessions(liveInputs, type).find(function (entry) {
      return String(entry.id) === String(ticket.sessionId);
    }) || {}).session : null;
    if (!frozenSession || !liveSession) return true;
    try {
      if (!ticket) return true;
      return !forceTargetAdapter.validateSession(ticket, { ...liveSession, testType: type }).ok;
    } catch (_) { return true; }
  });
  const batchExpectedTraceKey = function (session) {
    if (!session) return null;
    const signature = Array.isArray(session.traceRefs)
      ? session.traceRefs.map(function (ref) { return ref ? ref.id + '@' + ref.status + '@' + ref.schemaVersion : 'x'; }).join('|') : '';
    return session.id + '§' + signature;
  };
  const batchTraceBlocked = !!batchSnapshot && batchSnapshot.activeTab === 'cmj' && batchContexts.some(function (context) {
    const session = context.selection.cmj;
    if (!session || !Array.isArray(session.traceRefs) || !session.traceRefs.length) return false;
    const expected = batchExpectedTraceKey(session);
    const state = batchTraceStates.current[String(context.athlete.id)];
    return !state || state.forKey !== expected || (state.state !== 'ready' && state.state !== 'storage-error');
  });
  const batchBlocked = batchTargetBlocked || batchTraceBlocked;
  const noteBatchTraceState = function (athleteId) {
    return function (state) {
      batchTraceStates.current[String(athleteId)] = state;
      setBatchTraceTick(function (tick) { return tick + 1; });
    };
  };
  const frpGuardPrint = function (blocked) {
    if (blocked) {
      if (typeof window.alert === 'function') window.alert('原始曲线仍在加载 · 请稍候再导出（导出需等待原图就绪）。');
      return false;
    }
    return true;
  };

  const doPrint = function () {
    if (!frpGuardPrint(cmjCanvasBlocked || reportActiveTargetBlocked)) return;
    const node = document.getElementById('force-report-body');
    if (node && typeof window.doPrintBundle === 'function') {
      window.doPrintBundle([node], { title: (athlete ? athlete.name : 'Athlete') + ' · 测力台报告' });
    }
  };

  // FORCE-4: 全部 tab export — one page per active tab (综合 + each type with
  // data), same multi-node doPrintBundle call. The hidden staging bodies below
  // (one ForceReportPrintBody per tab, distinct bodyId) supply the DOM nodes.
  const doPrintAll = function () {
    if (!frpGuardPrint(cmjAllBlocked || reportAnyTargetBlocked)) return;
    if (typeof window.doPrintBundle !== 'function') return;
    const nodes = tabs
      .map(function (t) { return document.getElementById('force-all-' + t.id); })
      .filter(Boolean);
    if (!nodes.length) return;
    window.doPrintBundle(nodes, { title: (athlete ? athlete.name : 'Athlete') + ' · 测力台报告 · 全部' });
  };
  const doPrintBatch = async function () {
    if (!batchSnapshot || !batchContexts.length || !frpGuardPrint(batchBlocked)) return;
    const nodes = batchContexts.map(function (context) {
      return document.getElementById('force-batch-' + String(context.athlete.id).replace(/[^a-z0-9_-]/gi, '_'));
    }).filter(Boolean);
    if (nodes.length !== batchContexts.length) return;
    const tabLabel = (FRM.tabById(batchSnapshot.activeTab) || { label: batchSnapshot.activeTab }).label;
    if (batchExportMode === 'team-pdf') {
      if (typeof window.doPrintBundle !== 'function') return;
      window.doPrintBundle(nodes, { title:'测力台报告 · ' + tabLabel + ' · ' + nodes.length + ' athletes' });
      return;
    }
    if (!window.ReportBatchExporter || typeof window.ReportBatchExporter.exportIndependentPdfZip !== 'function') {
      setBatchExportState({ status:'error', completed:0, total:nodes.length, error:'独立 PDF 导出组件未就绪，请刷新页面后重试。' });
      return;
    }
    setBatchExportState({ status:'exporting', completed:0, total:nodes.length, error:'' });
    try {
      await window.ReportBatchExporter.exportIndependentPdfZip({
        archiveName:'测力台报告 - ' + tabLabel + ' - ' + nodes.length + '人',
        items:batchContexts.map(function (context, index) {
          const selectedSession = batchSnapshot.activeTab === 'overview' ? null : context.selection[batchSnapshot.activeTab];
          const sessionDate = selectedSession && (selectedSession.date || selectedSession.testDate);
          return {
            node:nodes[index],
            athleteId:context.athlete.id,
            fileName:context.athlete.name + ' - ' + tabLabel + (sessionDate ? ' - ' + sessionDate : ''),
          };
        }),
        onProgress:function (progress) {
          setBatchExportState({ status:'exporting', completed:progress.completed, total:progress.total, error:'' });
        },
      });
      setBatchExportState({ status:'done', completed:nodes.length, total:nodes.length, error:'' });
    } catch (error) {
      setBatchExportState({ status:'error', completed:0, total:nodes.length, error:error && error.message ? error.message : '独立 PDF ZIP 导出失败。' });
    }
  };

  const initials = (athlete && athlete.name ? athlete.name.split(' ').map(function (w) { return w[0]; }).join('').slice(0, 2).toUpperCase() : '—');

  return (
    <div id="force-report-root" className="force-rpt" onClick={function (e) { e.stopPropagation(); }}>
      {/* Header · athlete identity + RD-3 role label */}
      <div className="frp-head">
        <div className="frp-avatar">{initials}</div>
        <div>
          <div className="frp-id-name">{athlete ? athlete.name : '—'}</div>
          <div className="frp-id-role">测力台报告 · 技术源真值</div>
        </div>
        <button className="frp-x" onClick={onClose}>关闭</button>
      </div>

      {/* Tab row · 综合 + per-type-with-data */}
      <div className="frp-tabs" role="tablist">
        {tabs.map(function (t) {
          return (
            <button
              key={t.id}
              role="tab"
              className={'frp-tab' + (activeTab === t.id ? ' on' : '')}
              onClick={function () { setActiveTab(t.id); setSelectedModule(null); }}
            >{t.label}</button>
          );
        })}
        {batchSnapshot ? (
          <div className="frp-print-staging" aria-hidden="true">
            {batchContexts.map(function (context) {
              return (
                <ForceReportPrintBody
                  key={'batch-' + context.athlete.id}
                  bodyId={'force-batch-' + String(context.athlete.id).replace(/[^a-z0-9_-]/gi, '_')}
                  athlete={context.athlete}
                  activeTab={batchSnapshot.activeTab}
                  enabled={batchSnapshot.enabled}
                  moduleOrder={batchSnapshot.moduleOrder}
                  selection={context.selection}
                  inputs={context.inputs}
                  metricSel={batchSnapshot.metricSel}
                  vizMode={batchSnapshot.vizMode}
                  profileSection={batchSnapshot.profileSection}
                  percentileSort={batchSnapshot.percentileSort}
                  normKeyLegend={batchSnapshot.normKeyLegend}
                  referenceConfig={batchSnapshot.referenceConfig}
                  normRecords={normRecords}
                  compareSel={{}}
                  roster={roster}
                  inputsForAthlete={inputsForAthlete}
                  athleteCompareSel={batchSnapshot.athleteCompareSel}
                  curveMode={batchSnapshot.curveMode}
                  cmjTrialIndices={context.cmjTrialIndices}
                  traceRead={traceRead}
                  traceReadModel={traceReadModel}
                  onCmjTraceState={batchSnapshot.activeTab === 'cmj' ? noteBatchTraceState(context.athlete.id) : undefined}
                  branding={batchSnapshot.branding}
                />
              );
            })}
          </div>
        ) : null}
      </div>

      {/* Three-pane workbench */}
      <div className="frp-grid">
        {/* 模块库 */}
        <div className="frp-pane frp-library">
          <div className="frp-pane-title">模块库 · 点击开关</div>
          {activeMods.length ? activeMods.map(function (m) {
            const on = !!enabled[m.id];
            return (
              <button
                key={m.id}
                className={'frp-lib-i' + (on ? ' on' : '') + (selectedModule === m.id ? ' sel' : '')}
                onClick={function () { setSelectedModule(m.id); toggleModule(m.id); }}
                title={on ? '已在画布中 · 点击移除' : '点击加入画布'}
              >
                <span className="frp-lib-nm">{m.label}</span>
                <span className="frp-lib-state">{on ? '已用' : '＋'}</span>
              </button>
            );
          }) : <div className="frp-insp-note">本选项卡暂无模块（SJ / IMTP 模块见 FORCE-3）。</div>}
        </div>

        {/* 画布 · 纸面预览（即 #force-report-body 打印源） */}
        <div className="frp-pane frp-canvas">
          <ForceReportPrintBody
            athlete={athlete}
            activeTab={activeTab}
            enabled={enabled}
            moduleOrder={moduleOrder}
            selection={selection}
            inputs={safeInputs}
            metricSel={metricSel}
            vizMode={vizMode}
            profileSection={profileSection}
            percentileSort={percentileSort}
            normKeyLegend={normKeyLegend}
            referenceConfig={referenceConfig}
            normRecords={normRecords}
            compareSel={compareSel}
            roster={roster}
            inputsForAthlete={inputsForAthlete}
            athleteCompareSel={athleteCompareSel}
            curveMode={curveMode}
            cmjTrialIndices={reportTrialIndices}
            traceRead={traceRead}
            traceReadModel={traceReadModel}
            onCmjTraceState={noteCmjTraceState('canvas')}
            onCmjReadyTrials={noteCanvasReadyTrials}
            branding={branding ? Object.assign({}, branding, { showLogo: brandingOn }) : null}
          />
        </div>

        {/* 检查器 · 会话选择 + 选中模块配置 */}
        <div className="frp-pane frp-inspector">
          <div className="frp-insp-block">
            <div className="frp-insp-g">会话选择 · 各类型默认最新</div>
            {selectableTypes.length ? selectableTypes.map(function (r) {
              const cur = selection[r.t.id];
              const curId = cur ? cur.id : '';
              return (
                <label className="frp-select-row" key={r.t.id}>
                  <span className="frp-k">{r.t.label}</span>
                  <select value={String(curId)} onChange={function (e) { onSelectSession(r.t.id, e.target.value); }}>
                    {r.list.map(function (s) {
                      return <option key={String(s.id)} value={String(s.id)}>{frpDate(s.date)}</option>;
                    })}
                  </select>
                </label>
              );
            }) : <div className="frp-insp-note">该运动员暂无测力台会话。</div>}
          </div>

          <div className="frp-insp-block" data-force-report-branding>
            <div className="frp-insp-g">页眉 Logo · 机构共享</div>
            <div className="frp-branding-preview">
              {branding ? <img src={branding.logoDataUrl} alt="当前机构 Logo" /> : <span className="frp-insp-note">暂无 Logo</span>}
              {branding ? <span className="frp-insp-note">{branding.fileName}</span> : null}
            </div>
            <div className="frp-chips">
              <label className="frp-chip frp-branding-upload">
                {branding ? '替换 Logo' : '上传 Logo'}
                <input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleBrandingFile} aria-label="上传测力台报告 Logo" />
              </label>
              {branding ? <button className="frp-chip" onClick={removeBranding}>删除 Logo</button> : null}
              <button className={'frp-chip' + (branding && brandingOn ? ' on' : '')} disabled={!branding}
                onClick={function () { if (branding) setBrandingOn(function (value) { return !value; }); }}>
                {branding && brandingOn ? '页眉显示' : '页眉隐藏'}
              </button>
            </div>
            {brandingError ? <div className="frp-comment-state error" role="alert">{brandingError}</div> : null}
          </div>

          <div className="frp-insp-block" data-force-report-templates>
            <div className="frp-insp-g">报告模板 · 本地复用</div>
            <div className="frp-insp-note">保存页签、板块、顺序、指标、常模/队列、Logo 显示和 Trial 位置；不绑定当前运动员。</div>
            <div className="frp-chips" style={{ marginTop: 8 }}>
              <button type="button" className="frp-chip on" onClick={saveForceTemplate}>保存当前模板</button>
            </div>
            {forceTemplates.length ? (
              <div className="frp-template-list">
                {forceTemplates.map(function (template) {
                  return (
                    <span className={'frp-template-item' + (activeForceTemplateId === template.id ? ' on' : '')} key={template.id}>
                      <button type="button" className="frp-template-apply" onClick={function () { applyForceTemplate(template); }}
                        title={'应用模板：' + template.name}>{template.name}</button>
                      <button type="button" className="frp-template-delete" onClick={function () { deleteForceTemplate(template.id); }}
                        aria-label={'删除模板 ' + template.name} title="删除模板">×</button>
                    </span>
                  );
                })}
              </div>
            ) : <div className="frp-insp-note" style={{ marginTop: 7 }}>尚无模板。</div>}
          </div>

          <div className="frp-insp-block" data-force-report-batch>
            <div className="frp-insp-g">批量导出 · 当前报告模板</div>
            {!batchOpen || !batchSnapshot ? (
              <button type="button" className="frp-chip" onClick={lockBatchTemplate}>锁定当前模板并选择运动员</button>
            ) : (
              <div>
                <div className="frp-insp-note">已冻结 {FRM.tabById(batchSnapshot?.activeTab)?.label || activeTab} 的模块、顺序、指标、标准/常模、Logo、会话 revision 与每名运动员自己的代表 Trial。</div>
                {batchTargetBlocked ? <div className="frp-comment-state error" role="alert">源会话已更新或删除，请重新锁定模板后再导出。</div> : null}
                <div className="frp-insp-note" data-force-batch-selection style={{ marginTop: 8 }}>
                  已选择 {batchContexts.length} 名
                  {batchContexts.length
                    ? ' · ' + batchContexts.slice(0, 4).map(function (context) { return context.athlete.name; }).join('、')
                      + (batchContexts.length > 4 ? ' 等' : '')
                    : ' · 尚未选择可导出的运动员'}
                </div>
                <div className="frp-insp-g" style={{ marginTop: 10 }}>输出方式</div>
                <div className="frp-chips" data-force-batch-export-modes>
                  <button type="button" className={'frp-chip' + (batchExportMode === 'individual-zip' ? ' on' : '')}
                    onClick={function () { setBatchExportMode('individual-zip'); }}>
                    独立 PDF · ZIP
                  </button>
                  <button type="button" className={'frp-chip' + (batchExportMode === 'team-pdf' ? ' on' : '')}
                    onClick={function () { setBatchExportMode('team-pdf'); }}>
                    团队合并 · 单个 PDF
                  </button>
                </div>
                <div className="frp-insp-note" style={{ marginTop: 6 }}>
                  {batchExportMode === 'individual-zip'
                    ? '每名运动员生成一份独立 PDF，并下载为一个 ZIP 文件。'
                    : '所有运动员按花名册顺序进入一个打印任务；保存为一份多页 PDF。'}
                </div>
                <div className="frp-chips" style={{ marginTop: 8 }}>
                  <button type="button" className="frp-chip on" onClick={function () { openBatchPicker(batchSelectedIds); }}>
                    选择运动员 · 搜索 / 分组 / 队列人群
                  </button>
                  <button type="button" className="frp-chip" onClick={lockBatchTemplate}>重新锁定</button>
                  <button type="button" className={'frp-chip' + (!batchBlocked && batchContexts.length ? ' on' : '')}
                    disabled={batchBlocked || !batchContexts.length || batchExportState.status === 'exporting'} onClick={doPrintBatch}
                    data-force-batch-export-action>
                    {batchBlocked
                      ? '原图加载中…'
                      : batchExportState.status === 'exporting'
                        ? '后台生成中 ' + batchExportState.completed + ' / ' + batchExportState.total
                        : (batchExportMode === 'individual-zip' ? '导出独立 PDF ZIP · ' : '导出团队 PDF · ') + batchContexts.length + ' 人'}
                  </button>
                  <button type="button" className="frp-chip" onClick={function () {
                    setBatchPicker(null); setBatchOpen(false); setBatchSnapshot(null);
                  }}>取消</button>
                </div>
                {batchExportState.status === 'done' ? <div className="frp-comment-state success">ZIP 已生成并开始下载。</div> : null}
                {batchExportState.status === 'error' ? <div className="frp-comment-state error" role="alert">{batchExportState.error}</div> : null}
              </div>
            )}
          </div>

          {enabledActiveMods.length ? (
            <div className="frp-insp-block" data-frp-module-order={activeTab}>
              <div className="frp-insp-g">报告板块顺序 · 当前页签</div>
              <div className="frp-order-list">
                {enabledActiveMods.map(function (module, index) {
                  return (
                    <div className="frp-order-row" data-module-id={module.id} key={module.id}>
                      <span className="frp-order-name" title={module.label}>{module.label}</span>
                      <span className="frp-order-actions">
                        <button type="button" className="frp-order-btn" data-frp-move="up"
                          aria-label={'上移 ' + module.label} title="上移" disabled={index === 0}
                          onClick={function () { moveReportModule(module.id, -1); }}>↑</button>
                        <button type="button" className="frp-order-btn" data-frp-move="down"
                          aria-label={'下移 ' + module.label} title="下移" disabled={index === enabledActiveMods.length - 1}
                          onClick={function () { moveReportModule(module.id, 1); }}>↓</button>
                      </span>
                    </div>
                  );
                })}
              </div>
              <div className="frp-insp-note" style={{ marginTop: 7 }}>同步影响屏幕预览、当前页导出与全部页签导出；关闭后恢复默认顺序。</div>
            </div>
          ) : null}

          {activeTab === 'cmj' && reportCmjTrials.length ? (
            <div className="frp-insp-block" data-frp-trial-selector data-selected-count={reportTrialIndices.length}>
              <div className="frp-insp-g">报告曲线 · trial 多选</div>
              <div className="frp-chips">
                {reportCmjTrials.map(function (trial, index) {
                  const drawable = reportCmjDrawable.has(trial.index);
                  const runtimeLoading = !reportCmjLegacy && (!reportRuntimeMatches || reportRuntime.state === 'idle' || reportRuntime.state === 'loading');
                  const checked = drawable && reportTrialIndices.some(function (id) { return String(id) === String(trial.index); });
                  return (
                    <label key={trial.index != null ? trial.index : index} className={'frp-trial-chip' + (checked ? ' on' : '') + (!drawable ? ' unavailable' : '')} data-trial-index={trial.index}>
                      <input type="checkbox" checked={checked} disabled={!drawable} onChange={function () { toggleReportTrial(trial.index); }} />
                      <span>{'T' + (index + 1)}{String(trial.index) === String(reportCmjRepresentative) ? ' · 代表' : ''}{!drawable ? (runtimeLoading ? ' · 读取中' : ' · 不可用') : ''}</span>
                    </label>
                  );
                })}
              </div>
              <div className="frp-chip-head" style={{ marginTop: 7 }}>
                <span className="frp-chip-count">{reportTrialIndices.length}/{reportCmjDrawable.size} 可绘制</span>
                <button className="frp-chip-act" onClick={function () { setReportTrialChoice({ forKey: reportCmjTrialKey, trialIndices: reportCmjDefault == null ? [] : [reportCmjDefault] }); }}>{reportCmjDefault === reportCmjRepresentative ? '仅代表' : '首个可用'}</button>
                <button className="frp-chip-act" onClick={function () { setReportTrialChoice({ forKey: reportCmjTrialKey, trialIndices: Array.from(reportCmjDrawable) }); }}>全选可用</button>
              </div>
              <div className="frp-insp-note">同步控制归一化、F-D 与 F-V；原始 F-t 始终显示代表 trial。仅影响本次报告预览与导出。</div>
            </div>
          ) : null}

          {reportDraftScope ? (
            <div className="frp-insp-block">
              <label className="frp-insp-g" htmlFor="force-report-comment">专业评语 · 随当前会话</label>
              <textarea
                id="force-report-comment"
                className="frp-comment-input"
                value={effectiveReportComment}
                maxLength={reportDraftRepository ? reportDraftRepository.maxCommentLength : 4000}
                placeholder="填写需要随报告导出的专业评语；留空则报告中不显示该板块。"
                onChange={function (event) { saveReportComment(event.target.value); }}
              />
              <div className={'frp-comment-state' + (effectiveReportCommentState === 'error' ? ' error' : '')}>
                {effectiveReportCommentState === 'error' ? '本地保存失败 · 输入未写入报告，关闭前请复制内容'
                  : effectiveReportCommentState === 'unavailable' ? '草稿存储暂不可用 · 输入未写入报告'
                  : '自动保存 · 空内容不会出现在报告中'}
              </div>
            </div>
          ) : null}

          <div className="frp-insp-block">
            <div className="frp-insp-g">{selectedModule ? '模块 · ' + selectedModule : '模块配置'}</div>
            <div className="frp-insp-note">
              {selectedModule
                ? (activeMods.find(function (m) { return m.id === selectedModule; }) || {}).note || '基础模块。'
                : '点击左侧模块查看说明；切换会话后衍生指标与曲线自动重算。'}
            </div>
          </div>

          {selectedModule && FRP_PROFILE_MODULES[selectedModule] ? (function () {
            const meta = FRP_PROFILE_MODULES[selectedModule];
            const profiles = frpMetricProfiles(meta.typeId, selection[meta.typeId]);
            const selectedSections = frpSelectedProfileSections(profiles, profileSection[selectedModule]);
            const definitions = frpMetricDefs(selectedModule) || [];
            const selectedKeys = frpSelectedKeySet(metricSel, selectedModule, definitions);
            const visibleKeys = new Set();
            profiles.filter(function (profile) { return selectedSections.indexOf(profile.section) >= 0; })
              .forEach(function (profile) {
                profile.groups.forEach(function (group) {
                  group.rows.forEach(function (row) { visibleKeys.add(row.def.key); });
                });
              });
            return (
              <div className="frp-insp-block">
                <div className="frp-insp-g">指标剖面类别 · 多选</div>
                {profiles.length ? (
                  <div className="frp-seg" role="group" aria-label={meta.label + ' 指标剖面类别'}>
                    {profiles.map(function (profile) {
                      return <button key={profile.section} type="button" data-frp-profile-category={profile.section}
                        aria-pressed={selectedSections.indexOf(profile.section) >= 0}
                        className={selectedSections.indexOf(profile.section) >= 0 ? 'on' : ''}
                        onClick={function () { toggleModuleProfileSection(selectedModule, profile.section, profiles); }}>{profile.section}</button>;
                    })}
                  </div>
                ) : <div className="frp-insp-note">当前会话没有至少两个同类别、同单位的可呈现指标。</div>}
                <div className="frp-insp-note">默认仅选择首个推荐剖面；可同时加入多个类别。选择只影响本次报告预览与导出。</div>
                {selectedSections.length ? (
                  <div>
                    <div className="frp-insp-g" style={{ marginTop: 12 }}>剖面内指标</div>
                    <div className="frp-chip-head">
                      <span className="frp-chip-count">
                        {Array.from(visibleKeys).filter(function (key) { return selectedKeys.has(key); }).length}/{visibleKeys.size}
                      </span>
                      <button className="frp-chip-act" onClick={function () {
                        setMetricSel(function (prev) {
                          const current = frpSelectedKeySet(prev, selectedModule, definitions);
                          visibleKeys.forEach(function (key) { current.add(key); });
                          const next = Object.assign({}, prev);
                          next[selectedModule] = definitions.map(function (definition) { return definition.key; })
                            .filter(function (key) { return current.has(key); });
                          return next;
                        });
                      }}>全选已选剖面</button>
                      <button className="frp-chip-act" onClick={function () {
                        setMetricSel(function (prev) {
                          const current = frpSelectedKeySet(prev, selectedModule, definitions);
                          visibleKeys.forEach(function (key) { current.delete(key); });
                          const next = Object.assign({}, prev);
                          next[selectedModule] = definitions.map(function (definition) { return definition.key; })
                            .filter(function (key) { return current.has(key); });
                          return next;
                        });
                      }}>清空已选剖面</button>
                    </div>
                    {profiles.filter(function (profile) { return selectedSections.indexOf(profile.section) >= 0; }).map(function (profile) {
                      const keys = [];
                      profile.groups.forEach(function (group) {
                        group.rows.forEach(function (row) {
                          if (keys.indexOf(row.def.key) < 0) keys.push(row.def.key);
                        });
                      });
                      return <div key={profile.section} data-frp-profile-metrics={profile.section} style={{ marginTop: 9 }}>
                        <div className="frp-insp-note" style={{ marginBottom: 5 }}>{profile.section}</div>
                        <div className="frp-chips">
                          {keys.map(function (key) {
                            const definition = definitions.find(function (item) { return item.key === key; });
                            if (!definition) return null;
                            const on = selectedKeys.has(key);
                            return <button key={key} className={'frp-chip' + (on ? ' on' : '')}
                              onClick={function () { toggleMetricKey(selectedModule, key); }}>{definition.label}</button>;
                          })}
                        </div>
                      </div>;
                    })}
                  </div>
                ) : null}
              </div>
            );
          })() : null}

          {selectedModule === 'cmj-norm' ? (
            <div className="frp-insp-block" data-frp-keypoint-legend-toggle>
              <div className="frp-insp-g">关键点说明</div>
              <button type="button" className={'frp-chip' + (normKeyLegend ? ' on' : '')}
                aria-pressed={normKeyLegend}
                onClick={function () { setNormKeyLegend(function (value) { return !value; }); }}>
                {normKeyLegend ? '隐藏 a～g 中文说明' : '显示 a～g 中文说明'}
              </button>
            </div>
          ) : null}

          {/* FORCE-5a · metrics-module config: presentation switcher + metric
              multi-select chips (defaults: 表格 · 全选). Session-scoped state. */}
          {selectedModule && FRP_METRIC_MODULES[selectedModule] ? (function () {
            const defs = frpMetricDefs(selectedModule) || [];
            const selSet = frpSelectedKeySet(metricSel, selectedModule, defs);
            const curMode = vizMode[selectedModule] || 'table';
            const sections = window.ForceReportReferenceModel
              ? window.ForceReportReferenceModel.metricSections(defs) : [];
            return (
              <div className="frp-insp-block">
                <div className="frp-insp-g">呈现方式</div>
                <div className="frp-seg" role="group">
                  {FRP_VIZ_MODES.map(function (m) {
                    return (
                      <button key={m.id} className={curMode === m.id ? 'on' : ''}
                        onClick={function () { setModuleViz(selectedModule, m.id); }}>{m.label}</button>
                    );
                  })}
                </div>
                <div className="frp-insp-g">指标板块</div>
                <div className="frp-chips" data-frp-metric-sections={selectedModule}>
                  {sections.map(function (section) {
                    const sectionDefs = defs.filter(function (definition) { return String(definition.section || '指标') === section; });
                    const on = sectionDefs.length > 0 && sectionDefs.every(function (definition) { return selSet.has(definition.key); });
                    return (
                      <button key={section} className={'frp-chip' + (on ? ' on' : '')}
                        onClick={function () { setMetricSection(selectedModule, section, !on); }}>{section}</button>
                    );
                  })}
                </div>
                <div className="frp-insp-g">指标多选</div>
                <div className="frp-chip-head">
                  <span className="frp-chip-count">{selSet.size}/{defs.length}</span>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, true); }}>全选</button>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, false); }}>清空</button>
                </div>
                <div className="frp-chips">
                  {defs.map(function (d) {
                    const on = selSet.has(d.key);
                    return (
                      <button key={d.key} className={'frp-chip' + (on ? ' on' : '')}
                        onClick={function () { toggleMetricKey(selectedModule, d.key); }}>{d.label}</button>
                    );
                  })}
                </div>
              </div>
            );
          })() : null}

          {selectedModule && (FRP_METRIC_MODULES[selectedModule] || FRP_PROFILE_MODULES[selectedModule]) ? (function () {
            const typeId = frpModuleTypeId(selectedModule);
            const defs = frpMetricDefs(typeId + '-metrics') || [];
            const config = Object.assign({ mode: 'none', athleteIds: [], manualValues: {} }, referenceConfig[selectedModule] || {});
            const normSets = window.ForceReportReferenceModel
              ? window.ForceReportReferenceModel.listNormSets(normRecords, defs, typeId) : [];
            const visibleDefs = FRP_METRIC_MODULES[selectedModule]
              ? defs.filter(function (definition) { return frpSelectedKeySet(metricSel, selectedModule, defs).has(definition.key); })
              : (function () {
                  const profiles = frpMetricProfiles(typeId, selection[typeId]);
                  const sections = new Set(frpSelectedProfileSections(profiles, profileSection[selectedModule]));
                  const selectedKeys = frpSelectedKeySet(metricSel, selectedModule, defs);
                  return defs.filter(function (definition) {
                    return sections.has(String(definition.section || '指标')) && selectedKeys.has(definition.key);
                  });
                })();
            return (
              <div className="frp-insp-block" data-frp-reference-config={selectedModule}>
                <div className="frp-insp-g">标准 / 常模比较</div>
                <div className="frp-seg" role="group" aria-label="参考来源">
                  {[['none', '不比较'], ['norm', '常模'], ['cohort', '队列标准'], ['manual', '手工标准']].map(function (option) {
                    return <button key={option[0]} className={config.mode === option[0] ? 'on' : ''}
                      onClick={function () { updateReferenceConfig(selectedModule, { mode: option[0] }); }}>{option[1]}</button>;
                  })}
                </div>
                {(config.mode === 'norm' || config.mode === 'cohort') ? (
                  <button type="button"
                    className={'frp-chip' + (percentileSort[selectedModule] ? ' on' : '')}
                    aria-pressed={!!percentileSort[selectedModule]}
                    data-frp-percentile-sort={selectedModule}
                    onClick={function () {
                      setPercentileSort(function (prev) {
                        const next = Object.assign({}, prev);
                        next[selectedModule] = !prev[selectedModule];
                        return next;
                      });
                    }}>
                    显示数值百分位并按高→低排序
                  </button>
                ) : null}
                {config.mode === 'norm' ? (
                  normSets.length ? <label className="frp-select-row">
                    <span className="frp-k">已上传常模数据集</span>
                    <select value={config.normSetId || normSets[0].id}
                      onChange={function (event) { updateReferenceConfig(selectedModule, { normSetId: event.target.value }); }}>
                      {normSets.map(function (set) { return <option key={set.id} value={set.id}>{set.label} · {set.records.length} 指标</option>; })}
                    </select>
                  </label> : <div className="frp-insp-note">常模库中暂无与 {typeId.toUpperCase()} 指标键匹配的记录。</div>
                ) : null}
                {config.mode === 'cohort' ? (
                  <div>
                    <div className="frp-insp-note">每名运动员读取该类型最新会话；按指标计算均值与样本 SD，报告显示实际 n。</div>
                    <div className="frp-chips" style={{ marginTop: 7 }} data-ai-control="force-report-cohort-picker">
                      <button className="frp-chip on" onClick={function () { openReferencePicker(selectedModule); }}>
                        选择参照运动员 · {(config.athleteIds || []).length} 名
                      </button>
                      {(config.athleteIds || []).length ? <button className="frp-chip" onClick={function () {
                        updateReferenceConfig(selectedModule, { athleteIds: [] });
                      }}>清空选择</button> : null}
                    </div>
                    {(config.athleteIds || []).length
                      ? <div className="frp-insp-note" style={{ marginTop: 6 }}>
                          {(roster || []).filter(function (item) {
                            return (config.athleteIds || []).some(function (id) { return String(id) === String(item.id); });
                          }).slice(0, 4).map(function (item) { return item.name; }).join('、')}
                          {(config.athleteIds || []).length > 4 ? ' 等' : ''}
                        </div>
                      : <div className="frp-insp-note" style={{ marginTop: 6 }}>尚未选择参照运动员，不会生成队列标准。</div>}
                  </div>
                ) : null}
                {config.mode === 'manual' ? (
                  <div className="frp-ref-actions">
                    {visibleDefs.map(function (definition) {
                      return <label key={definition.key} title={definition.label}>
                        <span className="frp-k">{definition.label}</span>
                        <input type="number" inputMode="decimal" placeholder={definition.unit || '目标值'}
                          value={(config.manualValues || {})[definition.key] ?? ''}
                          onChange={function (event) {
                            const values = Object.assign({}, config.manualValues || {});
                            values[definition.key] = event.target.value;
                            updateReferenceConfig(selectedModule, { manualValues: values });
                          }} />
                      </label>;
                    })}
                  </div>
                ) : null}
                {config.mode !== 'none' ? <div className="frp-insp-note" style={{ marginTop: 7 }}>只比较已保存值；不重跑检测、不生成等级或处方。</div> : null}
              </div>
            );
          })() : null}

          {/* FORCE-5b · compare-module config: session multi-select (chronological
              date chips, default latest 3, cap 5) + metric multi-select chips
              (reuses the FORCE-5a metricSel idiom). Session-scoped state. */}
          {selectedModule && FRP_COMPARE_MODULES[selectedModule] ? (function () {
            const typeId = FRP_COMPARE_MODULES[selectedModule].typeId;
            const list = FSS.listSessions(safeInputs, typeId); // newest-first
            const chrono = list.slice().reverse(); // chronological ascending chips
            const selIds = frpCompareSelectedIds(compareSel, selectedModule, list);
            const selIdSet = new Set(selIds.map(function (id) { return String(id); }));
            const atCap = selIds.length >= FRP_COMPARE_MAX;
            const defs = frpMetricDefs(selectedModule) || [];
            const mSelSet = frpSelectedKeySet(metricSel, selectedModule, defs);
            return (
              <div className="frp-insp-block">
                <div className="frp-insp-g">会话多选 · 时间升序</div>
                <div className="frp-chip-head">
                  <span className="frp-chip-count">{selIds.length}/{list.length} · 最多 {FRP_COMPARE_MAX}</span>
                </div>
                <div className="frp-chips">
                  {chrono.map(function (s) {
                    const on = selIdSet.has(String(s.id));
                    const capped = !on && atCap;
                    return (
                      <button key={String(s.id)}
                        className={'frp-chip' + (on ? ' on' : '')}
                        disabled={capped}
                        title={capped ? ('最多选择 ' + FRP_COMPARE_MAX + ' 次会话') : ''}
                        style={capped ? { opacity: 0.4, cursor: 'not-allowed' } : null}
                        onClick={function () { toggleCompareSession(selectedModule, s.id, list); }}>{frpDate(s.date)}</button>
                    );
                  })}
                </div>
                {selIds.length < 2
                  ? <div className="frp-insp-note" style={{ marginTop: 6, color: 'var(--neg, var(--muted))' }}>至少选择两次会话。</div>
                  : atCap
                    ? <div className="frp-insp-note" style={{ marginTop: 6 }}>已达上限 {FRP_COMPARE_MAX} 次（打印宽度约束）。</div>
                    : null}
                <div className="frp-insp-g" style={{ marginTop: 12 }}>指标多选</div>
                <div className="frp-chip-head">
                  <span className="frp-chip-count">{mSelSet.size}/{defs.length}</span>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, true); }}>全选</button>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, false); }}>清空</button>
                </div>
                <div className="frp-chips">
                  {defs.map(function (d) {
                    const on = mSelSet.has(d.key);
                    return (
                      <button key={d.key} className={'frp-chip' + (on ? ' on' : '')}
                        onClick={function () { toggleMetricKey(selectedModule, d.key); }}>{d.label}</button>
                    );
                  })}
                </div>
              </div>
            );
          })() : null}

          {/* FORCE-5c · athlete-compare-module config: comparison-athlete multi-select
              (roster minus current athlete, default NONE, cap 4 — disabled chips at
              cap + hint) + metric multi-select chips (reuses the FORCE-5a metricSel
              idiom). Each comparison athlete uses their LATEST session of the type
              (no per-opponent session picker this slice). Session-scoped state. */}
          {selectedModule && FRP_ATHLETE_COMPARE_MODULES[selectedModule] ? (function () {
            const selIds = frpAthleteCompareSelectedIds(athleteCompareSel, selectedModule);
            const selIdSet = new Set(selIds.map(function (id) { return String(id); }));
            const atCap = selIds.length >= FRP_ATHLETE_COMPARE_MAX;
            const defs = frpMetricDefs(selectedModule) || [];
            const mSelSet = frpSelectedKeySet(metricSel, selectedModule, defs);
            return (
              <div className="frp-insp-block">
                <div className="frp-insp-g">对比运动员 · 各自最新会话</div>
                <div className="frp-chip-head">
                  <span className="frp-chip-count">{selIds.length}/{compareRoster.length} · 最多 {FRP_ATHLETE_COMPARE_MAX}</span>
                </div>
                {compareRoster.length ? (
                  <div className="frp-chips">
                    {compareRoster.map(function (a) {
                      const on = selIdSet.has(String(a.id));
                      const capped = !on && atCap;
                      return (
                        <button key={String(a.id)}
                          className={'frp-chip' + (on ? ' on' : '')}
                          disabled={capped}
                          title={capped ? ('最多选择 ' + FRP_ATHLETE_COMPARE_MAX + ' 名运动员') : ''}
                          style={capped ? { opacity: 0.4, cursor: 'not-allowed' } : null}
                          onClick={function () { toggleCompareAthlete(selectedModule, a.id); }}>{a.name}</button>
                      );
                    })}
                  </div>
                ) : <div className="frp-insp-note">花名册中暂无其他运动员可对比。</div>}
                {selIds.length < 1
                  ? <div className="frp-insp-note" style={{ marginTop: 6 }}>选择对比运动员（首列固定为本人）。</div>
                  : atCap
                    ? <div className="frp-insp-note" style={{ marginTop: 6 }}>已达上限 {FRP_ATHLETE_COMPARE_MAX} 名（打印宽度约束）。</div>
                    : null}
                <div className="frp-insp-g" style={{ marginTop: 12 }}>指标多选</div>
                <div className="frp-chip-head">
                  <span className="frp-chip-count">{mSelSet.size}/{defs.length}</span>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, true); }}>全选</button>
                  <button className="frp-chip-act" onClick={function () { setAllMetricKeys(selectedModule, false); }}>清空</button>
                </div>
                <div className="frp-chips">
                  {defs.map(function (d) {
                    const on = mSelSet.has(d.key);
                    return (
                      <button key={d.key} className={'frp-chip' + (on ? ' on' : '')}
                        onClick={function () { toggleMetricKey(selectedModule, d.key); }}>{d.label}</button>
                    );
                  })}
                </div>
              </div>
            );
          })() : null}

          {/* FORCE-5d · curve-compare config: 对比模式 toggle (会话对比 / 运动员对比)
              + the mode's REUSED selection chips — session mode reuses the 5b compareSel
              map (toggleCompareSession, cap 5); athlete mode reuses the 5c
              athleteCompareSel map (toggleCompareAthlete, cap 4). NO metric chips (curves
              carry no metric selection). Session-scoped state. */}
          {selectedModule && FRP_CURVE_COMPARE_MODULES[selectedModule] ? (function () {
            const typeId = FRP_CURVE_COMPARE_MODULES[selectedModule].typeId;
            const mode = curveMode[selectedModule] || 'session';
            return (
              <div className="frp-insp-block">
                <div className="frp-insp-g">对比模式</div>
                <div className="frp-seg" role="group">
                  <button className={mode === 'session' ? 'on' : ''} onClick={function () { setModuleCurveMode(selectedModule, 'session'); }}>会话对比</button>
                  <button className={mode === 'athlete' ? 'on' : ''} onClick={function () { setModuleCurveMode(selectedModule, 'athlete'); }}>运动员对比</button>
                </div>
                {mode === 'session' ? (function () {
                  const list = FSS.listSessions(safeInputs, typeId); // newest-first
                  const chrono = list.slice().reverse();
                  const selIds = frpCompareSelectedIds(compareSel, selectedModule, list);
                  const selIdSet = new Set(selIds.map(function (id) { return String(id); }));
                  const atCap = selIds.length >= FRP_COMPARE_MAX;
                  return (
                    <div>
                      <div className="frp-insp-g">会话多选 · 时间升序</div>
                      <div className="frp-chip-head">
                        <span className="frp-chip-count">{selIds.length}/{list.length} · 最多 {FRP_COMPARE_MAX}</span>
                      </div>
                      <div className="frp-chips">
                        {chrono.map(function (s) {
                          const on = selIdSet.has(String(s.id));
                          const capped = !on && atCap;
                          return (
                            <button key={String(s.id)}
                              className={'frp-chip' + (on ? ' on' : '')}
                              disabled={capped}
                              title={capped ? ('最多选择 ' + FRP_COMPARE_MAX + ' 次会话') : ''}
                              style={capped ? { opacity: 0.4, cursor: 'not-allowed' } : null}
                              onClick={function () { toggleCompareSession(selectedModule, s.id, list); }}>{frpDate(s.date)}</button>
                          );
                        })}
                      </div>
                      {selIds.length < 2
                        ? <div className="frp-insp-note" style={{ marginTop: 6, color: 'var(--neg, var(--muted))' }}>至少选择两次会话。</div>
                        : atCap
                          ? <div className="frp-insp-note" style={{ marginTop: 6 }}>已达上限 {FRP_COMPARE_MAX} 次（打印宽度约束）。</div>
                          : null}
                    </div>
                  );
                })() : (function () {
                  const selIds = frpAthleteCompareSelectedIds(athleteCompareSel, selectedModule);
                  const selIdSet = new Set(selIds.map(function (id) { return String(id); }));
                  const atCap = selIds.length >= FRP_ATHLETE_COMPARE_MAX;
                  return (
                    <div>
                      <div className="frp-insp-g">对比运动员 · 各自最新会话</div>
                      <div className="frp-chip-head">
                        <span className="frp-chip-count">{selIds.length}/{compareRoster.length} · 最多 {FRP_ATHLETE_COMPARE_MAX}</span>
                      </div>
                      {compareRoster.length ? (
                        <div className="frp-chips">
                          {compareRoster.map(function (a) {
                            const on = selIdSet.has(String(a.id));
                            const capped = !on && atCap;
                            return (
                              <button key={String(a.id)}
                                className={'frp-chip' + (on ? ' on' : '')}
                                disabled={capped}
                                title={capped ? ('最多选择 ' + FRP_ATHLETE_COMPARE_MAX + ' 名运动员') : ''}
                                style={capped ? { opacity: 0.4, cursor: 'not-allowed' } : null}
                                onClick={function () { toggleCompareAthlete(selectedModule, a.id); }}>{a.name}</button>
                            );
                          })}
                        </div>
                      ) : <div className="frp-insp-note">花名册中暂无其他运动员可对比。</div>}
                      {selIds.length < 1
                        ? <div className="frp-insp-note" style={{ marginTop: 6 }}>选择对比运动员（首条曲线固定为本人）。</div>
                        : atCap
                          ? <div className="frp-insp-note" style={{ marginTop: 6 }}>已达上限 {FRP_ATHLETE_COMPARE_MAX} 名（打印宽度约束）。</div>
                          : null}
                    </div>
                  );
                })()}
              </div>
            );
          })() : null}
        </div>
      </div>

      {/* Export foot · 当前 tab 单页 / 全部 tab 多页打包（FORCE-4） */}
      <div className="frp-foot">
        <span className="frp-insp-note">当前选项卡单页 · 或全部选项卡打包为多页（每 tab 一页）</span>
        {reportActiveTargetBlocked || reportAnyTargetBlocked
          ? <span className="frp-comment-state error" role="alert">源会话已更新或删除，请重新选择会话后再导出。</span> : null}
        <span className="frp-grow"></span>
        <button className="frp-ghost" onClick={onClose}>取消</button>
        <button className="frp-ghost" onClick={doPrintAll} disabled={cmjAllBlocked || reportAnyTargetBlocked}>{cmjAllBlocked ? '原图加载中…' : '导出 · 全部 tab'}</button>
        <button className="frp-pri" onClick={doPrint} disabled={cmjCanvasBlocked || reportActiveTargetBlocked}>{cmjCanvasBlocked ? '原图加载中…' : ('导出 · ' + (FRM.tabById(activeTab) ? FRM.tabById(activeTab).label : ''))}</button>
      </div>

      <ForceReferenceCohortPicker
        state={referencePicker}
        roster={roster}
        currentAthlete={athlete}
        onChange={setReferencePicker}
        onApply={applyReferencePicker}
        onClose={function () { setReferencePicker(null); }}
      />
      <ForceReferenceCohortPicker
        state={batchPicker}
        roster={batchEligibleRoster}
        currentAthlete={athlete}
        title="选择批量导出运动员"
        subtitle="搜索、筛选，或直接载入锁定模板中的队列人群"
        sourceOptions={batchCohortSources}
        showExcludeCurrent={false}
        onChange={setBatchPicker}
        onApply={applyBatchPicker}
        onClose={function () { setBatchPicker(null); }}
      />

      {/* FORCE-4: hidden all-tab staging — one print body per available tab with a
          distinct bodyId (force-all-<tab>) so 导出·全部tab collects them as N
          nodes for doPrintBundle (one page each). Off-screen, never interactive. */}
      <div style={{ position: 'absolute', width: 794, left: -10000, top: 0, visibility: 'hidden', pointerEvents: 'none' }} aria-hidden="true">
        {tabs.map(function (t) {
          return (
            <ForceReportPrintBody
              key={t.id}
              bodyId={'force-all-' + t.id}
              athlete={athlete}
              activeTab={t.id}
              enabled={enabled}
              moduleOrder={moduleOrder}
              selection={selection}
              inputs={safeInputs}
              metricSel={metricSel}
              vizMode={vizMode}
              profileSection={profileSection}
              percentileSort={percentileSort}
              normKeyLegend={normKeyLegend}
              referenceConfig={referenceConfig}
              normRecords={normRecords}
              compareSel={compareSel}
              roster={roster}
              inputsForAthlete={inputsForAthlete}
              athleteCompareSel={athleteCompareSel}
              curveMode={curveMode}
              cmjTrialIndices={reportTrialIndices}
              traceRead={traceRead}
              traceReadModel={traceReadModel}
              onCmjTraceState={t.id === 'cmj' ? noteCmjTraceState('all-cmj') : undefined}
              branding={branding ? Object.assign({}, branding, { showLogo: brandingOn }) : null}
            />
          );
        })}
      </div>
    </div>
  );
}

// Window exposure. ForceReportModules is published by its IIFE above; re-listing
// it here keeps both public surfaces on one sanctioned Object.assign site.
const ForceReportModules = window.ForceReportModules;
Object.assign(window, { ForceReportWorkbench, ForceReportModules });
