// dashboard-views.jsx  v4  —  个人视图布局：HeadlineTiles · RadarChart · Leaderboard · ACWR · SWC · Trend
// 职责：个人运动员仪表盘的所有 Section 组件，消费 app.jsx 传入的 athlete/squad 数据
// 依赖：charts.jsx · swc.jsx · components.jsx · data.js（metricScore01）
//
// All net-new components for the Lab/Coach mode redesign live here so the
// Step 1 surface area is easy to review. Exposes via window.* :
//   ModeToggle          — top-bar Lab/Coach segmented control
//   InsightFeed         — Coach Mode squad-page main panel
//   HeadlineTilesRow    — Coach Mode athlete-page Status Board (current value + Δ + 队内 percentile; catalog-driven)
//   SessionDeltaTable   — Coach Mode athlete-page per-session per-metric delta heatmap
//   CoachIndividualView — the assembled athlete page for Coach Mode

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

  // ── Utility ────────────────────────────────────────────────────────────────
  const sortAsc  = arr => [...arr].sort((a, b) => new Date(a) - new Date(b));
  const sortDesc = arr => [...arr].sort((a, b) => new Date(b) - new Date(a));
  const fmt = (v, p = 2) => v == null || !isFinite(v) ? '—' :
    Math.abs(v) >= 100 ? v.toFixed(0) :
    Math.abs(v) >= 10  ? v.toFixed(1) :
                         v.toFixed(p);

  // ── ModeToggle ─────────────────────────────────────────────────────────────
  function ModeToggle({ mode, onChange }) {
    const opts = [
      { id: 'lab',   label: 'Lab',   hint: '科研深度视图（默认）' },
      { id: 'coach', label: 'Coach', hint: '决策摘要 / 洞察 Feed' },
    ];
    return (
      <div style={{
        display: 'inline-flex', background: 'var(--panel-hi)',
        border: '1px solid var(--border)', borderRadius: 6, padding: 2,
      }}>
        {opts.map(o => {
          const active = mode === o.id;
          return (
            <button key={o.id} onClick={() => onChange(o.id)} title={o.hint}
              style={{
                padding: '4px 12px', borderRadius: 4, cursor: 'pointer',
                border: 'none', background: active ? 'var(--accent)' : 'transparent',
                color: active ? 'white' : 'var(--muted)',
                fontSize: 11, fontWeight: 600, fontFamily: 'var(--font-sans)',
                letterSpacing: '.04em', transition: 'background .12s',
              }}>{o.label}</button>
          );
        })}
      </div>
    );
  }

  // ── Severity styling ───────────────────────────────────────────────────────
  const SEV = {
    risk:     { color: '#f87171', bg: 'rgba(248,113,113,.10)', border: 'rgba(248,113,113,.35)', icon: '🚨', label: '需要关注' },
    warn:     { color: '#fbbf24', bg: 'rgba(251,191,36,.10)',  border: 'rgba(251,191,36,.35)',  icon: '⚠️',  label: '预警观察' },
    positive: { color: '#34d399', bg: 'rgba(52,211,153,.10)',  border: 'rgba(52,211,153,.35)',  icon: '✨', label: '本周亮点' },
    info:     { color: '#60a5fa', bg: 'rgba(96,165,250,.10)',  border: 'rgba(96,165,250,.35)',  icon: '📅', label: '待安排' },
  };

  // ── InsightFeed ────────────────────────────────────────────────────────────
  // Hard caps per severity level so the feed stays scannable:
  //   risk  ≤ 8  (always shown)
  //   warn  ≤ 6  (always shown)
  //   positive/info: collapsed by default to just a count summary; user expands
  const SEV_CAPS = { risk: 8, warn: 6, positive: 0, info: 0 };  // 0 = collapsed initially
  const SEV_OPEN_CAPS = { risk: 8, warn: 6, positive: 10, info: 10 };  // when section is expanded

  function InsightFeed({ athletes, groups, seasons, squadStatsAll, cmjStore, algo, onSelectAthlete, onOpenRules }) {
    const [tick, setTick] = useState(0);  // force re-eval after dismiss
    // Track which severity sections are user-expanded (positive/info default folded)
    const [openSections, setOpenSections] = useState({ risk: true, warn: true, positive: false, info: false });

    const insights = useMemo(() => {
      if (!window.INSIGHTS) return [];
      return window.INSIGHTS.evaluateAll({
        athletes, groups, seasons, squadStatsAll, cmjStore, algo,
        today: new Date().toISOString().slice(0, 10),
        maxPerRule: 5,
      });
    }, [athletes, groups, seasons, squadStatsAll, cmjStore, algo, tick]);

    const grouped = useMemo(() => {
      const g = { risk: [], warn: [], positive: [], info: [] };
      insights.forEach(i => g[i.severity]?.push(i));
      return g;
    }, [insights]);

    const handleDismiss = (ins) => {
      window.INSIGHTS?.dismissInsight(ins.dismissKey);
      setTick(t => t + 1);
    };

    const handleResetDismissed = () => {
      window.INSIGHTS?.clearDismissed();
      setTick(t => t + 1);
    };

    const toggleSection = (sev) => setOpenSections(o => ({ ...o, [sev]: !o[sev] }));

    const todayLabel = new Date().toISOString().slice(0, 10);

    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {/* Feed header */}
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '4px 2px',
        }}>
          <div>
            <div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              今日简报 · {todayLabel}
            </div>
            <div style={{ fontSize: 18, fontWeight: 600, color: 'var(--text)', marginTop: 2 }}>
              {insights.length} 条洞察
              <span style={{ fontSize: 12, color: 'var(--muted)', marginLeft: 8, fontWeight: 400 }}>
                · {grouped.risk.length} 风险 · {grouped.warn.length} 预警 · {grouped.positive.length} 亮点 · {grouped.info.length} 提醒
              </span>
            </div>
          </div>
          <div style={{ display: 'flex', gap: 6 }}>
            <button className="btn ghost" onClick={handleResetDismissed} style={{ fontSize: 11, color: 'var(--muted)' }} title="恢复所有已忽略的洞察">
              ↺ 恢复忽略
            </button>
            {onOpenRules && (
              <button className="btn" onClick={onOpenRules} style={{ fontSize: 11 }}>
                ⚙ 规则配置
              </button>
            )}
          </div>
        </div>

        {/* Empty state */}
        {insights.length === 0 && (
          <div style={{
            padding: '32px 20px', background: 'var(--panel)',
            border: '1px dashed var(--border)', borderRadius: 10,
            display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
            color: 'var(--muted)', fontSize: 13,
          }}>
            <span style={{ fontSize: 28 }}>✓</span>
            <span>没有触发任何规则。所有指标都在正常范围内。</span>
          </div>
        )}

        {/* Severity groups (capped + collapsible) */}
        {['risk', 'warn', 'positive', 'info'].map(sev => {
          const list = grouped[sev];
          if (!list || list.length === 0) return null;
          const style = SEV[sev];
          const isOpen = openSections[sev];
          const cap = isOpen ? SEV_OPEN_CAPS[sev] : SEV_CAPS[sev];
          const visible = list.slice(0, cap);
          const hidden = list.length - visible.length;

          return (
            <div key={sev} style={{
              background: 'var(--panel)', border: '1px solid var(--border)',
              borderLeft: `3px solid ${style.color}`, borderRadius: 8,
              overflow: 'hidden',
            }}>
              <button onClick={() => toggleSection(sev)} style={{
                width: '100%', background: style.bg, border: 'none', cursor: 'pointer',
                padding: '10px 14px', fontSize: 11, fontWeight: 700, color: style.color,
                textTransform: 'uppercase', letterSpacing: '.06em',
                display: 'flex', alignItems: 'center', gap: 8,
                fontFamily: 'var(--font-sans)', textAlign: 'left',
              }}>
                <span>{style.icon}</span>
                <span>{style.label}</span>
                <span style={{ marginLeft: 'auto', fontWeight: 500, opacity: .85, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                  {list.length} 条
                  <span style={{ fontSize: 9, opacity: .75 }}>{isOpen ? '隐藏' : '显示'}</span>
                  <span style={{ fontSize: 9, transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>▼</span>
                </span>
              </button>
              {isOpen && (
                <div style={{ display: 'flex', flexDirection: 'column' }}>
                  {visible.map(ins => (
                    <InsightRow key={ins.id} insight={ins} onSelectAthlete={onSelectAthlete} onDismiss={() => handleDismiss(ins)}/>
                  ))}
                  {hidden > 0 && (
                    <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--muted)', textAlign: 'center', borderTop: '1px solid var(--border)' }}>
                      +{hidden} 条同级别未显示 — 在 Settings 调整规则参数以减少触发
                    </div>
                  )}
                </div>
              )}
            </div>
          );
        })}
      </div>
    );
  }

  function InsightRow({ insight, onSelectAthlete, onDismiss }) {
    const [hover, setHover] = useState(false);
    return (
      <div
        onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
        style={{
          padding: '12px 14px', borderTop: '1px solid var(--border)',
          display: 'flex', alignItems: 'flex-start', gap: 12,
          background: hover ? 'rgba(15,23,42,.035)' : 'transparent',
          transition: 'background .1s',
        }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 12.5, color: 'var(--text)', fontWeight: 500, marginBottom: 3 }}>
            {insight.title}
          </div>
          <div style={{ fontSize: 11.5, color: 'var(--muted)', lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
            {insight.body}
          </div>
          <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 6, display: 'flex', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)' }}>{insight.ruleLabel}</span>
          </div>
        </div>
        <div style={{ display: 'flex', gap: 4, opacity: hover ? 1 : 0.6, transition: 'opacity .15s' }}>
          {insight.athleteId && onSelectAthlete && (
            <button onClick={() => onSelectAthlete(insight.athleteId)} className="btn"
              style={{ fontSize: 10.5, padding: '3px 8px' }}>
              查看 →
            </button>
          )}
          <button onClick={onDismiss} className="btn ghost"
            style={{ fontSize: 10.5, padding: '3px 8px', color: 'var(--muted-2)' }}
            title="忽略这条洞察（仅今天）">
            ✕
          </button>
        </div>
      </div>
    );
  }

  // ── HeadlineTilesRow ───────────────────────────────────────────────────────
  // MB-2 状态板 · Status Board:纯当前值快照。每格走统一指标目录
  // (window.AthleteMetricCatalog) 的 getCurrent 取「值 + Δ」;seasons 指标保留队内/
  // 同位置百分位条,force 指标(无队内同类)只显示 力板徽标 + 值 + Δ。关注集
  // pinnedMetricIds 现在可含 force 前缀 id;上限 8 格。无迷你趋势线、点格不再联动
  // 下方趋势图(activeMetric 解耦)。
  const MAX_TILES = 8;
  const AUTO_FALLBACK_N = 6;

  function HeadlineTilesRow({ athlete, athletes, groups, seasons, squadStatsAll, currentSeason, cmjStore, sjStore, imtpStore, pinnedMetricIds, onChangePinnedMetricIds }) {
    // 百分位参照系:队内(全队 squad) / 同位置(同位置 cohort)。默认队内。
    const [refMode, setRefMode] = useState('team');
    const catalog = (typeof window !== 'undefined' && window.AthleteMetricCatalog) || null;
    const inputs = useMemo(
      () => ({ athlete, cmjStore, sjStore, imtpStore }),
      [athlete, cmjStore, sjStore, imtpStore]
    );

    // Full catalog entry list (seasons + force), for pinned resolution + picker.
    const catalogEntries = useMemo(
      () => (catalog ? catalog.listCatalog(inputs) : []),
      [catalog, inputs]
    );
    const entryById = useMemo(() => {
      const m = new Map();
      catalogEntries.forEach(e => m.set(e.id, e));
      return m;
    }, [catalogEntries]);

    // Build a status-board cell for ANY catalog id via the catalog's getCurrent.
    // seasons cells carry a队内/同位置 percentile (existing logic, latest date from
    // getSeries); force cells carry none (no squad cohort → honest no-bar).
    const buildCell = (id) => {
      const entry = entryById.get(id);
      if (!catalog || !entry) return null;
      const cur = catalog.getCurrent(inputs, id);
      if (cur.value == null) return null;
      let pct = null;
      if (entry.origin === 'seasons') {
        const series = catalog.getSeries(inputs, id);
        const lastDate = series.length ? series[series.length - 1].date : null;
        if (lastDate) {
          const ss = squadStatsAll[lastDate]?.[id];
          let refVals = ss?.values;
          if (refMode === 'position' && Array.isArray(athletes)) {
            refVals = athletes
              .filter(a => a.position === athlete.position)
              .map(a => a.seasons?.[lastDate]?.[id])
              .filter(v => v != null && isFinite(v));
          }
          if (refVals && refVals.length > 1) {
            const sorted = [...refVals].sort((a, b) => a - b);
            let lt = 0, eq = 0;
            for (const v of sorted) { if (v < cur.value) lt++; else if (v === cur.value) eq++; }
            const p01 = (lt + 0.5 * eq) / sorted.length;
            pct = Math.round((entry.dir === 'lower' ? 1 - p01 : p01) * 100);
          }
        }
      }
      return { id, entry, value: cur.value, delta: cur.delta, pct };
    };

    // Auto-pick fallback (no pins): top N SEASONS entries with data, ranked by
    // |delta| (nulls last). Force is opt-in via pin, so it never auto-fills.
    const autoIds = useMemo(() => {
      const ranked = catalogEntries
        .filter(e => e.origin === 'seasons')
        .map(e => {
          const cur = catalog ? catalog.getCurrent(inputs, e.id) : { value: null, delta: null };
          return cur.value == null ? null : { id: e.id, absDelta: cur.delta == null ? null : Math.abs(cur.delta) };
        })
        .filter(Boolean);
      ranked.sort((a, b) => {
        if ((a.absDelta == null) !== (b.absDelta == null)) return a.absDelta == null ? 1 : -1;
        return (b.absDelta || 0) - (a.absDelta || 0);
      });
      return ranked.slice(0, AUTO_FALLBACK_N).map(r => r.id);
    }, [catalog, catalogEntries, inputs]);

    // Resolve the ordered cell list: pinned override (in pinned order) → auto.
    const pinned = Array.isArray(pinnedMetricIds) ? pinnedMetricIds : null;
    const activeIds = (pinned ? pinned : autoIds).slice(0, MAX_TILES);
    const cells = useMemo(
      () => activeIds.map(buildCell).filter(Boolean),
      [activeIds.join('|'), catalog, entryById, inputs, squadStatsAll, refMode, athletes]
    );

    // ＋pin appends a catalog id to the pinned set (starting from the current
    // active set so an auto board becomes an explicit pinned set on first pin).
    const appendMetric = (id) => {
      const base = pinned ? pinned : activeIds;
      if (base.includes(id)) return;
      onChangePinnedMetricIds && onChangePinnedMetricIds([...base, id].slice(0, MAX_TILES));
    };
    // ⋯ 更换指标:swap this cell's id in place.
    const replaceMetric = (index, id) => {
      const base = pinned ? [...pinned] : [...activeIds];
      if (index < 0 || index >= base.length) return;
      base[index] = id;
      onChangePinnedMetricIds && onChangePinnedMetricIds(base.slice(0, MAX_TILES));
    };
    // ⋯ 移除:drop this cell from the pinned set.
    const removeMetric = (index) => {
      const base = pinned ? [...pinned] : [...activeIds];
      if (index < 0 || index >= base.length) return;
      base.splice(index, 1);
      onChangePinnedMetricIds && onChangePinnedMetricIds(base);
    };
    const resetAuto = () => {
      onChangePinnedMetricIds && onChangePinnedMetricIds(null);
    };

    // 已选 id(用于 picker 排除)与是否已满。
    const pickedIds = cells.map(c => c.id);
    const atCap = pickedIds.length >= MAX_TILES;

    if (cells.length === 0) {
      return (
        <div style={{
          padding: 16, background: 'var(--panel)', border: '1px dashed var(--border)',
          borderRadius: 10, color: 'var(--muted)', fontSize: 12, textAlign: 'center',
        }}>
          暂无指标数据可显示。请先在 Data Entry 录入测试结果。
        </div>
      );
    }

    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
          <div style={{ display: 'inline-flex', alignItems: 'baseline', gap: 9 }}>
            <b style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text)' }}>状态板 · Status Board</b>
            <small style={{ fontSize: 10.5, color: 'var(--muted)' }}>此刻 · 无趋势</small>
          </div>
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
            <div style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
              <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 600 }}>百分位参照</span>
              <div style={{ display: 'inline-flex', border: '1px solid var(--border)', borderRadius: 5, overflow: 'hidden' }}>
                {[['team', '队内'], ['position', '同位置']].map(([v, l]) => (
                  <button key={v} onClick={() => setRefMode(v)} title={v === 'position' ? `同位置:${athlete.position}` : '全队'} style={{
                    padding: '2px 11px', fontSize: 10.5, fontWeight: 600, cursor: 'pointer', border: 0, fontFamily: 'var(--font-sans)',
                    background: refMode === v ? 'var(--accent)' : 'var(--panel)', color: refMode === v ? '#fff' : 'var(--muted)',
                  }}>{l}</button>
                ))}
              </div>
            </div>
            {pinned && (
              <button onClick={resetAuto} style={{ fontSize: 10.5, padding: '2px 8px', borderRadius: 3, background: 'transparent', border: '1px solid var(--border)', color: 'var(--muted)', cursor: 'pointer', fontFamily: 'var(--font-sans)', letterSpacing: '.04em' }}>Reset to auto</button>
            )}
            <PinPicker entries={catalogEntries} pickedIds={pickedIds} disabled={atCap} onPick={appendMetric}/>
          </div>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
          {cells.map((c, i) => (
            <HeadlineTile
              key={c.id}
              cell={c}
              entries={catalogEntries}
              pickedIds={pickedIds}
              onReplace={(metricId) => replaceMetric(i, metricId)}
              onRemove={() => removeMetric(i)}
            />
          ))}
        </div>
      </div>
    );
  }

  // Catalog-backed metric picker popover (MB-2). Groups entries by origin
  // (seasons + the three force groups); force groups carry the 力板 origin
  // label. Clicking an entry fires onPick(id) and closes. `excludeIds` hides
  // already-pinned ids so the same metric can't be added twice.
  const ORIGIN_META = {
    'seasons':    { label: '队内指标', force: false },
    'force-cmj':  { label: '力板 · CMJ', force: true },
    'force-sj':   { label: '力板 · SJ', force: true },
    'force-imtp': { label: '力板 · IMTP', force: true },
  };
  const ORIGIN_ORDER = ['seasons', 'force-cmj', 'force-sj', 'force-imtp'];

  function MetricCatalogPicker({ entries, current, excludeIds, align, onPick, onClose, anchorRef }) {
    const popRef = useRef(null);
    useEffect(() => {
      const h = (e) => {
        if (!popRef.current) return;
        if (popRef.current.contains(e.target)) return;
        if (anchorRef?.current?.contains(e.target)) return;
        onClose();
      };
      document.addEventListener('mousedown', h);
      const k = (e) => { if (e.key === 'Escape') onClose(); };
      document.addEventListener('keydown', k);
      return () => {
        document.removeEventListener('mousedown', h);
        document.removeEventListener('keydown', k);
      };
    }, []);
    const excluded = new Set((excludeIds || []).filter(id => id !== current));
    const grouped = ORIGIN_ORDER
      .map(origin => ({
        origin,
        meta: ORIGIN_META[origin],
        items: entries.filter(e => e.origin === origin && !excluded.has(e.id)),
      }))
      .filter(g => g.items.length > 0);
    return (
      <div ref={popRef} onClick={e => e.stopPropagation()} style={{
        position: 'absolute', top: 'calc(100% + 4px)', [align === 'left' ? 'left' : 'right']: 0, zIndex: 60,
        width: 260, maxHeight: 360, overflowY: 'auto',
        background: 'var(--panel)', border: '1px solid var(--border-strong)',
        borderRadius: 4, boxShadow: '0 12px 32px rgba(15,18,15,.2)',
        padding: '6px 0', animation: 'fadeUp .15s ease both',
      }}>
        {grouped.map(g => (
          <div key={g.origin}>
            <div style={{
              padding: '6px 12px 4px',
              fontSize: 9, color: g.meta.force ? 'var(--force-fg, #4f46c9)' : 'var(--muted)',
              letterSpacing: '.12em', textTransform: 'uppercase', fontWeight: 700,
              display: 'flex', alignItems: 'center', gap: 6,
            }}>
              <span style={{ width: 5, height: 5, borderRadius: 1, background: g.meta.force ? 'var(--force-fg, #4f46c9)' : 'var(--muted-2)' }}/>
              {g.meta.label}
            </div>
            {g.items.map(m => {
              const isCurrent = m.id === current;
              return (
                <div key={m.id} onClick={() => { onPick(m.id); onClose(); }}
                  style={{
                    padding: '5px 12px', cursor: 'pointer',
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    background: isCurrent ? 'var(--accent-soft)' : 'transparent',
                    borderLeft: `3px solid ${isCurrent ? 'var(--accent)' : 'transparent'}`,
                  }}
                  onMouseEnter={(e) => { if (!isCurrent) e.currentTarget.style.background = 'var(--panel-2)'; }}
                  onMouseLeave={(e) => { if (!isCurrent) e.currentTarget.style.background = 'transparent'; }}>
                  <span style={{ fontSize: 12, color: 'var(--text)' }}>{m.label}</span>
                  {m.unit && <span className="mono" style={{ fontSize: 10, color: 'var(--muted)' }}>{m.unit}</span>}
                </div>
              );
            })}
          </div>
        ))}
      </div>
    );
  }

  // Header ＋pin control — opens the catalog picker to APPEND a metric.
  function PinPicker({ entries, pickedIds, disabled, onPick }) {
    const [open, setOpen] = useState(false);
    const anchorRef = useRef(null);
    if (disabled) {
      return (
        <span title="关注集已达上限 8" style={{ fontSize: 11, padding: '3px 10px', border: '1px solid var(--border)', borderRadius: 6, color: 'var(--muted-2)', background: 'var(--panel)', opacity: .55 }}>
          ＋ pin
        </span>
      );
    }
    return (
      <span ref={anchorRef} style={{ position: 'relative', display: 'inline-flex' }}>
        <button onClick={() => setOpen(o => !o)} title="关注一个指标" style={{
          fontSize: 11, padding: '3px 10px', border: '1px solid var(--border)', borderRadius: 6,
          color: 'var(--muted)', background: 'var(--panel)', cursor: 'pointer', fontFamily: 'var(--font-sans)',
        }}>＋ pin</button>
        {open && (
          <MetricCatalogPicker entries={entries} current={null} excludeIds={pickedIds} align="right"
            onPick={onPick} onClose={() => setOpen(false)} anchorRef={anchorRef}/>
        )}
      </span>
    );
  }

  // MB-2 status-board cell. Pure current-value snapshot: eyebrow + big mono
  // value + unit + colored Δ chip. seasons cells add the队内/同位置 percentile
  // bar; force cells add the 力板 badge and NO percentile (no squad cohort).
  // A ⋯ menu (top-right) offers 更换指标 (catalog swap) / 移除 (unpin). No
  // sparkline, no tile→trend selection.
  function HeadlineTile({ cell, entries, pickedIds, onReplace, onRemove }) {
    const [menuOpen, setMenuOpen] = useState(false);
    const [pickerOpen, setPickerOpen] = useState(false);
    const anchorRef = useRef(null);
    const menuRef = useRef(null);

    useEffect(() => {
      if (!menuOpen) return;
      const h = (e) => {
        if (menuRef.current && menuRef.current.contains(e.target)) return;
        if (anchorRef.current && anchorRef.current.contains(e.target)) return;
        setMenuOpen(false);
      };
      document.addEventListener('mousedown', h);
      const k = (e) => { if (e.key === 'Escape') setMenuOpen(false); };
      document.addEventListener('keydown', k);
      return () => {
        document.removeEventListener('mousedown', h);
        document.removeEventListener('keydown', k);
      };
    }, [menuOpen]);

    const { entry, value, delta, pct } = cell;
    const isForce = entry.origin !== 'seasons';
    const dArrow = delta == null ? '' : delta > 0 ? '▲' : delta < 0 ? '▼' : '–';

    return (
      <div style={{
        position: 'relative',
        background: 'var(--panel)', border: '1px solid var(--border)',
        borderRadius: 11, padding: '13px 15px',
        display: 'flex', flexDirection: 'column', gap: 10,
      }}>
        {/* 力板 badge (force cells only) — mockup top-right purple pill */}
        {isForce && (
          <span style={{
            position: 'absolute', top: 7, right: 8, fontSize: 7.5, fontWeight: 700, letterSpacing: '.03em',
            borderRadius: 4, padding: '1px 5px', background: 'var(--force-bg, #ecebfb)', color: 'var(--force-fg, #4f46c9)',
          }}>力板</span>
        )}

        {/* ⋯ menu — 更换指标 / 移除 */}
        <button
          ref={anchorRef}
          onClick={(e) => { e.stopPropagation(); setMenuOpen(o => !o); }}
          title="更多"
          className="no-print"
          style={{
            position: 'absolute', top: 6, right: isForce ? 34 : 6,
            width: 22, height: 22, borderRadius: 3,
            background: 'transparent', border: '1px solid transparent',
            color: 'var(--muted-2)', cursor: 'pointer',
            display: 'grid', placeItems: 'center',
            fontSize: 13, lineHeight: 1, padding: 0, opacity: .7,
          }}
          onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-hi)'; e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--text-2)'; e.currentTarget.style.opacity = 1; }}
          onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'transparent'; e.currentTarget.style.color = 'var(--muted-2)'; e.currentTarget.style.opacity = .7; }}>
          ⋯
        </button>
        {menuOpen && (
          <div ref={menuRef} onClick={e => e.stopPropagation()} style={{
            position: 'absolute', top: 30, right: 6, zIndex: 60,
            minWidth: 120, background: 'var(--panel)', border: '1px solid var(--border-strong)',
            borderRadius: 6, boxShadow: '0 12px 32px rgba(15,18,15,.2)', padding: '4px 0',
            animation: 'fadeUp .15s ease both',
          }}>
            <div onClick={() => { setMenuOpen(false); setPickerOpen(true); }}
              style={{ padding: '6px 12px', cursor: 'pointer', fontSize: 12, color: 'var(--text)' }}
              onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
              更换指标
            </div>
            <div onClick={() => { setMenuOpen(false); onRemove && onRemove(); }}
              style={{ padding: '6px 12px', cursor: 'pointer', fontSize: 12, color: 'var(--neg)' }}
              onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
              移除
            </div>
          </div>
        )}
        {pickerOpen && (
          <MetricCatalogPicker entries={entries} current={entry.id} excludeIds={pickedIds} align="right"
            onPick={onReplace} onClose={() => setPickerOpen(false)} anchorRef={anchorRef}/>
        )}

        {/* eyebrow 标签 */}
        <div style={{ fontSize: 10, letterSpacing: '.08em', textTransform: 'uppercase', fontWeight: 600, color: 'var(--muted-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: 26 }}>{entry.label}</div>
        {/* 值 + 变化(按 dir 校正好坏配色) */}
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
          <span style={{ display: 'flex', alignItems: 'baseline', gap: 3, minWidth: 0 }}>
            <span className="mono" style={{ fontSize: 24, fontWeight: 600, color: 'var(--text)', letterSpacing: '-.02em' }}>{fmt(value)}</span>
            {entry.unit && <span style={{ fontSize: 11, color: 'var(--muted)' }}>{entry.unit}</span>}
          </span>
          {delta != null && delta !== 0 && (() => {
            const good = entry.dir === 'lower' ? delta < 0 : delta > 0;
            return (
              <span className="mono" style={{ fontSize: 11, fontWeight: 600, padding: '2px 7px', borderRadius: 6, flexShrink: 0,
                color: good ? 'var(--pos)' : 'var(--neg)', background: good ? 'rgba(22,163,74,.12)' : 'rgba(220,38,38,.10)' }}>{dArrow} {fmt(Math.abs(delta))}</span>
            );
          })()}
        </div>
        {/* 百分位条 (seasons only) — 方向渐变轨 + 中段参照(IQR 25–75% + 中位)+ 端标 队尾·Pxx·队首 */}
        {pct != null && (() => {
          const band = pct >= 67 ? 'var(--pos)' : pct >= 34 ? 'var(--warn)' : 'var(--neg)';
          const desc = pct >= 67 ? '偏强' : pct >= 34 ? '中游' : '偏弱';
          const x = Math.max(0, Math.min(100, pct));
          return (
            <div style={{ marginTop: 2 }}>
              <div style={{ position: 'relative' }}>
                <div style={{ position: 'relative', height: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid var(--border)',
                  background: 'linear-gradient(90deg, rgba(220,38,38,.12) 0%, var(--panel-hi) 42%, var(--panel-hi) 58%, rgba(22,163,74,.14) 100%)' }}>
                  <div style={{ position: 'absolute', top: 0, bottom: 0, left: '25%', right: '25%', background: 'rgba(15,18,15,.06)' }}/>
                  <div style={{ position: 'absolute', top: 0, bottom: 0, left: '50%', width: 2, background: 'var(--muted-2)', opacity: .7, transform: 'translateX(-50%)' }}/>
                </div>
                <div style={{ position: 'absolute', top: '50%', left: `${x}%`, width: 13, height: 13, borderRadius: 999, background: band, border: '2.5px solid var(--panel)', transform: 'translate(-50%,-50%)', boxShadow: '0 1px 4px rgba(15,18,15,.3)' }}/>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 6, fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--muted-2)' }}>
                <span>队尾</span>
                <span style={{ color: band, fontWeight: 600 }}>P{pct} · {desc}</span>
                <span>队首</span>
              </div>
            </div>
          );
        })()}
      </div>
    );
  }

  // ── SessionDeltaTable ──────────────────────────────────────────────────────
  // Rows = recent sessions (newest top); columns = metrics; cells colored by SWC band.
  function SessionDeltaTable({ athlete, groups, seasons, squadStatsAll, maxSessions = 8 }) {
    const [timeRange, setTimeRange] = useState('all');
    const data = useMemo(() => {
      const now = Date.now();
      const cutoff = timeRange === '4w' ? now - 28 * 86400000
        : timeRange === '3m' ? now - 91 * 86400000
        : null;
      const datedSessions = sortDesc(seasons)
        .map(s => ({ date: s, vals: athlete.seasons?.[s] || null }))
        .filter(x => x.vals && Object.keys(x.vals).length)
        .filter(x => !cutoff || +new Date(x.date) >= cutoff)
        .slice(0, maxSessions);
      const allMetrics = groups.flatMap(g => g.metrics.map(m => ({ ...m, group: g.label, accent: g.accent })));
      // Keep only metrics actually present in at least one shown session
      const presentMetrics = allMetrics.filter(m => datedSessions.some(s => s.vals[m.id] != null));
      return { sessions: datedSessions, metrics: presentMetrics };
    }, [athlete, groups, seasons, maxSessions, timeRange]);

    if (!data.sessions.length) {
      return (
        <div style={{
          padding: 16, background: 'var(--panel)', border: '1px dashed var(--border)',
          borderRadius: 10, color: 'var(--muted)', fontSize: 12, textAlign: 'center',
        }}>
          暂无可对比的历史会话。
        </div>
      );
    }

    // Build prior-comparison map: for each (session, metric), compare to that
    // metric's value in the next-older session. Top row has no Δ.
    const cellW = 70, dateW = 110;

    const colorForSwc = (swcDelta, improved) => {
      if (swcDelta == null) return null;
      const a = Math.abs(swcDelta);
      if (a < 0.2) return null;  // trivial — no fill
      const hue = improved ? '52,211,153' : '248,113,113';  // green / red
      const alpha = a >= 1.2 ? 0.28 : a >= 0.6 ? 0.18 : 0.08;
      return `rgba(${hue},${alpha})`;
    };

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
        <div style={{
          padding: '10px 14px', borderBottom: '1px solid var(--border)',
          fontSize: 10, fontWeight: 700, color: 'var(--muted)',
          textTransform: 'uppercase', letterSpacing: '.06em',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <span>Session × Metric · 按 SWC 着色</span>
            {['4w', '3m', 'all'].map(r => (
              <button key={r} onClick={() => setTimeRange(r)} style={{
                padding: '1px 7px', borderRadius: 4, cursor: 'pointer', fontSize: 10,
                background: timeRange === r ? 'var(--accent-soft)' : 'transparent',
                border: '1px solid ' + (timeRange === r ? 'rgba(59,130,246,.35)' : 'var(--border)'),
                color: timeRange === r ? 'var(--accent-2)' : 'var(--muted)',
                fontFamily: 'var(--font-sans)', fontWeight: 600,
              }}>{ r === '4w' ? '近4周' : r === '3m' ? '近3月' : '全部' }</button>
            ))}
          </div>
          <span style={{ fontWeight: 400, fontSize: 10, color: 'var(--muted-2)' }}>
            {data.sessions.length} 次 · {data.metrics.length} 个指标
          </span>
        </div>
        <div style={{ overflowX: 'auto' }}>
          <div style={{ minWidth: 'max-content', padding: '4px 0' }}>
            {/* Header row */}
            <div style={{ display: 'flex', alignItems: 'flex-end', borderBottom: '1px solid var(--border)', padding: '4px 0' }}>
              <div style={{ width: dateW, minWidth: dateW, padding: '4px 12px', fontSize: 10, color: 'var(--muted)', fontWeight: 600 }}>
                Date
              </div>
              {data.metrics.map(m => (
                <div key={m.id} style={{
                  width: cellW, minWidth: cellW, padding: '4px 6px',
                  fontSize: 9.5, color: m.accent || 'var(--muted)', fontWeight: 600,
                  textAlign: 'right', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                }} title={m.label}>
                  {m.label}
                </div>
              ))}
            </div>
            {/* Rows */}
            {data.sessions.map((sess, ri) => {
              const prior = data.sessions[ri + 1] || null;
              return (
                <div key={sess.date} style={{
                  display: 'flex', alignItems: 'center',
                  background: ri % 2 === 0 ? 'transparent' : 'rgba(15,23,42,.03)',
                  borderBottom: '1px solid rgba(15,23,42,.05)',
                }}>
                  <div style={{ width: dateW, minWidth: dateW, padding: '6px 12px', fontSize: 11, color: ri === 0 ? 'var(--text)' : 'var(--text-2)', fontWeight: ri === 0 ? 600 : 400 }}>
                    {sess.date}
                  </div>
                  {data.metrics.map(m => {
                    const v = sess.vals[m.id];
                    const pv = prior?.vals?.[m.id];
                    const delta = v != null && pv != null ? v - pv : null;
                    const improved = delta != null ? (m.dir === 'lower' ? delta < 0 : delta > 0) : null;
                    const ss = squadStatsAll[sess.date]?.[m.id];
                    const swc = ss?.values && window.DASHBOARD_DATA
                      ? 0.2 * window.DASHBOARD_DATA.stddev(ss.values) : null;
                    const swcDelta = (delta != null && swc != null && swc > 0) ? delta / swc : null;
                    const bg = colorForSwc(swcDelta, improved);
                    const marker = swcDelta == null ? '' :
                      Math.abs(swcDelta) >= 1.2 ? (improved ? '▲▲' : '▼▼') :
                      Math.abs(swcDelta) >= 0.6 ? (improved ? '▲'  : '▼')  : '';
                    return (
                      <div key={m.id} style={{
                        width: cellW, minWidth: cellW, padding: '6px 6px',
                        fontSize: 11, textAlign: 'right',
                        fontFamily: 'var(--font-mono)', color: 'var(--text-2)',
                        background: bg || 'transparent',
                        opacity: v == null ? 0.3 : 1,
                      }} title={delta != null ? `Δ${delta > 0 ? '+' : ''}${delta.toFixed(2)} · SWC ${swcDelta != null ? swcDelta.toFixed(2) + '×' : '—'}` : ''}>
                        {marker && <span style={{ fontSize: 8, marginRight: 3, opacity: .7 }}>{marker}</span>}
                        {v == null ? '—' : fmt(v)}
                      </div>
                    );
                  })}
                </div>
              );
            })}
          </div>
        </div>
        {/* Legend */}
        <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', display: 'flex', flexWrap: 'wrap', gap: 12 }}>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(52,211,153,.28)', borderRadius: 2, marginRight: 4, verticalAlign: 'middle' }}/>有意义提升 (≥1.2 SWC)</span>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(52,211,153,.18)', borderRadius: 2, marginRight: 4, verticalAlign: 'middle' }}/>中等提升 (0.6-1.2)</span>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(248,113,113,.18)', borderRadius: 2, marginRight: 4, verticalAlign: 'middle' }}/>中等下降</span>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(248,113,113,.28)', borderRadius: 2, marginRight: 4, verticalAlign: 'middle' }}/>有意义下降</span>
        </div>
      </div>
    );
  }

  // ── Per-Metric Selector ────────────────────────────────────────────────────
  // Compact metric picker scoped to a single chart. Different from MetricPicker
  // in components.jsx (which is for the athlete trend chart): this one lives
  // at the top of a per-metric squad view and updates a shared metricId.
  function PerMetricSelector({ groups, metricId, onChange, starred }) {
    const [open, setOpen] = useState(false);
    const allMetrics = groups.flatMap(g => g.metrics.map(m => ({ ...m, group: g.label, accent: g.accent })));
    const current = allMetrics.find(m => m.id === metricId) || allMetrics[0];
    const starSet = new Set(starred || []);

    return (
      <div style={{ position: 'relative' }}>
        <button onClick={() => setOpen(o => !o)} style={{
          background: 'var(--panel-hi)', border: '1px solid var(--border)',
          borderRadius: 6, padding: '5px 10px', cursor: 'pointer',
          color: 'var(--text)', fontSize: 12, fontFamily: 'var(--font-sans)',
          display: 'inline-flex', alignItems: 'center', gap: 6,
        }}>
          {starSet.has(current?.id) && <span style={{ color: '#fbbf24', fontSize: 10 }}>★</span>}
          {current?.label || '选择指标'}
          {current?.unit && <span style={{ color: 'var(--muted)', fontSize: 10 }}>{current.unit}</span>}
          <span style={{ color: 'var(--muted)', fontSize: 9 }}>▼</span>
        </button>
        {open && (
          <div style={{
            position: 'absolute', top: '100%', left: 0, marginTop: 4,
            background: 'var(--panel)', border: '1px solid var(--border-strong)',
            borderRadius: 8, minWidth: 280, maxHeight: 360, overflowY: 'auto',
            zIndex: 50, boxShadow: '0 8px 24px rgba(0,0,0,.4)',
          }}>
            {groups.map(g => (
              <div key={g.id}>
                <div style={{ padding: '6px 12px', fontSize: 9.5, fontWeight: 700, color: g.accent || 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', background: 'var(--panel-hi)' }}>
                  {g.label}
                </div>
                {g.metrics.map(m => (
                  <button key={m.id}
                    onClick={() => { onChange(m.id); setOpen(false); }}
                    style={{
                      display: 'flex', width: '100%', padding: '6px 12px',
                      background: m.id === metricId ? 'var(--accent-soft)' : 'transparent',
                      border: 'none', cursor: 'pointer', textAlign: 'left',
                      color: m.id === metricId ? 'var(--accent-2)' : 'var(--text-2)',
                      fontSize: 12, fontFamily: 'var(--font-sans)',
                      alignItems: 'center', gap: 6,
                    }}>
                    {starSet.has(m.id) && <span style={{ color: '#fbbf24', fontSize: 9 }}>★</span>}
                    <span style={{ flex: 1 }}>{m.label}</span>
                    {m.unit && <span style={{ color: 'var(--muted)', fontSize: 10 }}>{m.unit}</span>}
                  </button>
                ))}
              </div>
            ))}
          </div>
        )}
      </div>
    );
  }

  // ── PerMetricDistribution ──────────────────────────────────────────────────
  // Horizontal dot plot showing every athlete's latest value for one metric.
  // Overlays squad mean ± 1SD ± SWC band so outliers are obvious at a glance.
  function PerMetricDistribution({ athletes, metric, currentDate, squadStats, onSelectAthlete }) {
    const D = window.DASHBOARD_DATA;
    const [hoverId, setHoverId] = useState(null);

    const data = useMemo(() => {
      return athletes.map(a => ({
        id: a.id, name: a.name, position: a.position,
        value: a.seasons?.[currentDate]?.[metric.id],
      })).filter(d => d.value != null && isFinite(d.value));
    }, [athletes, metric, currentDate]);

    const stat = useMemo(() => {
      const ss = squadStats?.[metric.id];
      const vals = ss?.values || data.map(d => d.value);
      if (vals.length < 2) return null;
      const m = vals.reduce((a, b) => a + b, 0) / vals.length;
      const sd = D ? D.stddev(vals) : 0;
      const ci = bootstrapMeanCI(vals, 400);
      return {
        mean: m, sd, swc: 0.2 * sd,
        min: Math.min(...vals), max: Math.max(...vals), n: vals.length,
        ciLo: ci?.lo, ciHi: ci?.hi,
      };
    }, [data, squadStats, metric]);

    if (!data.length || !stat) {
      return (
        <div style={{
          padding: 16, background: 'var(--panel)', border: '1px dashed var(--border)',
          borderRadius: 10, color: 'var(--muted)', fontSize: 12, textAlign: 'center',
        }}>
          {metric.label} 在 {currentDate} 没有可用数据
        </div>
      );
    }

    const W = 720, H = 110, ML = 50, MR = 40, MT = 26, MB = 30;
    const PW = W - ML - MR, PH = H - MT - MB;
    const pad = (stat.max - stat.min) * 0.08 || 1;
    const xMin = stat.min - pad, xMax = stat.max + pad;
    const xS = v => ML + ((v - xMin) / (xMax - xMin)) * PW;

    // Tick marks (5)
    const ticks = [];
    const step = (xMax - xMin) / 5;
    for (let i = 0; i <= 5; i++) ticks.push(+(xMin + i * step).toFixed(2));

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6, gap: 10, flexWrap: 'wrap' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              Squad Distribution
            </span>
            <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{metric.label}</span>
            {metric.unit && <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>{metric.unit}</span>}
            <SampleSizeChip n={stat.n}/>
          </div>
          <span className="mono" style={{ fontSize: 10, color: 'var(--muted)' }}>
            μ = {fmt(stat.mean)} {stat.ciLo != null && <span title="Bootstrap 95% CI on mean">[{fmt(stat.ciLo)}, {fmt(stat.ciHi)}]</span>}
            {' '}· σ = {fmt(stat.sd)} · SWC ±{fmt(stat.swc)}
          </span>
        </div>
        <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', display: 'block' }}>
          {/* SWC band (±0.2 SD) */}
          <rect x={xS(stat.mean - stat.swc)} y={MT} width={xS(stat.mean + stat.swc) - xS(stat.mean - stat.swc)} height={PH}
            fill="rgba(96,165,250,.10)"/>
          {/* ±1 SD band */}
          <rect x={xS(stat.mean - stat.sd)} y={MT + PH * 0.25} width={xS(stat.mean + stat.sd) - xS(stat.mean - stat.sd)} height={PH * 0.5}
            fill="none" stroke="rgba(96,165,250,.30)" strokeDasharray="3 3" strokeWidth="1"/>
          {/* Bootstrap 95% CI on mean — drawn as a small bracket above the axis */}
          {stat.ciLo != null && stat.ciHi != null && (
            <g>
              <line x1={xS(stat.ciLo)} x2={xS(stat.ciHi)} y1={MT + PH + 2} y2={MT + PH + 2}
                stroke="rgba(96,165,250,.85)" strokeWidth="2"/>
              <line x1={xS(stat.ciLo)} x2={xS(stat.ciLo)} y1={MT + PH - 2} y2={MT + PH + 6}
                stroke="rgba(96,165,250,.85)" strokeWidth="1.2"/>
              <line x1={xS(stat.ciHi)} x2={xS(stat.ciHi)} y1={MT + PH - 2} y2={MT + PH + 6}
                stroke="rgba(96,165,250,.85)" strokeWidth="1.2"/>
            </g>
          )}
          {/* Mean line */}
          <line x1={xS(stat.mean)} y1={MT} x2={xS(stat.mean)} y2={MT + PH}
            stroke="rgba(96,165,250,.6)" strokeWidth="1.2"/>
          {/* Axis */}
          <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.18)"/>
          {ticks.map(t => (
            <g key={t}>
              <line x1={xS(t)} y1={MT + PH} x2={xS(t)} y2={MT + PH + 4} stroke="rgba(15,23,42,.22)"/>
              <text x={xS(t)} y={MT + PH + 16} textAnchor="middle" fontSize="9" fill="var(--muted)">{fmt(t)}</text>
            </g>
          ))}
          {/* Dots */}
          {data.map(d => {
            const hovered = hoverId === d.id;
            return (
              <g key={d.id} style={{ cursor: 'pointer' }}
                onMouseEnter={() => setHoverId(d.id)} onMouseLeave={() => setHoverId(null)}
                onClick={() => onSelectAthlete && onSelectAthlete(d.id)}>
                <circle cx={xS(d.value)} cy={MT + PH / 2} r={hovered ? 6 : 4.5}
                  fill={hovered ? 'var(--accent)' : 'var(--accent-2)'}
                  fillOpacity={hovered ? 1 : 0.75}
                  stroke="var(--bg)" strokeWidth="1.5"/>
                {hovered && (
                  <g style={{ pointerEvents: 'none' }}>
                    <text x={xS(d.value)} y={MT + PH / 2 - 12} textAnchor="middle" fontSize="10" fill="var(--text)" fontWeight="600">
                      {d.name}
                    </text>
                    <text x={xS(d.value)} y={MT + PH / 2 + 22} textAnchor="middle" fontSize="9" fill="var(--muted)">
                      {fmt(d.value)} {metric.unit || ''}
                    </text>
                  </g>
                )}
              </g>
            );
          })}
          {/* Mean label */}
          <text x={xS(stat.mean)} y={MT - 4} textAnchor="middle" fontSize="9" fill="rgba(96,165,250,.7)">μ</text>
        </svg>
        <div style={{ fontSize: 10, color: 'var(--muted-2)', display: 'flex', gap: 14, paddingTop: 4, flexWrap: 'wrap' }}>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(96,165,250,.10)', verticalAlign: 'middle', marginRight: 4 }}/>SWC 带 (±0.2σ)</span>
          <span><span style={{ display: 'inline-block', width: 10, height: 0, borderTop: '1.5px dashed rgba(96,165,250,.6)', verticalAlign: 'middle', marginRight: 4 }}/>±1σ</span>
          <span><span style={{ display: 'inline-block', width: 12, height: 6, borderTop: '2px solid rgba(96,165,250,.85)', borderLeft: '1.2px solid rgba(96,165,250,.85)', borderRight: '1.2px solid rgba(96,165,250,.85)', verticalAlign: 'middle', marginRight: 4 }}/>Bootstrap 95% CI on mean</span>
          <span style={{ marginLeft: 'auto', fontStyle: 'italic' }}>悬浮查看运动员 · 点击进入个人页</span>
        </div>
      </div>
    );
  }

  // ── PerMetricLeaderboard ───────────────────────────────────────────────────
  // Top-N ranking on one metric: rank · athlete · current value ·
  // Δ vs prior session · sparkline.
  function PerMetricLeaderboard({ athletes, groups, metric, currentDate, seasons, squadStats, onSelectAthlete, topN = 10 }) {
    const D = window.DASHBOARD_DATA;

    const rows = useMemo(() => {
      const sortedDates = sortAsc(seasons);
      const currIdx = sortedDates.indexOf(currentDate);
      const prevDate = currIdx > 0 ? sortedDates[currIdx - 1] : null;

      const enriched = athletes.map(a => {
        const v = a.seasons?.[currentDate]?.[metric.id];
        if (v == null || !isFinite(v)) return null;
        const pv = prevDate ? a.seasons?.[prevDate]?.[metric.id] : null;
        const delta = (pv != null && isFinite(pv)) ? v - pv : null;
        const hist = sortedDates
          .map(d => ({ x: d, y: a.seasons?.[d]?.[metric.id] }))
          .filter(p => p.y != null && isFinite(p.y))
          .slice(-8);
        return { athlete: a, value: v, prev: pv, delta, hist };
      }).filter(Boolean);

      // Sort by metric direction
      enriched.sort((a, b) => metric.dir === 'lower' ? a.value - b.value : b.value - a.value);
      return enriched.slice(0, topN);
    }, [athletes, metric, currentDate, seasons, topN]);

    // SWC for Δ chip
    const swc = useMemo(() => {
      const ss = squadStats?.[metric.id];
      const vals = ss?.values;
      if (!vals || vals.length < 2 || !D) return null;
      return 0.2 * D.stddev(vals);
    }, [squadStats, metric]);

    if (!rows.length) {
      return (
        <div style={{
          padding: 16, background: 'var(--panel)', border: '1px dashed var(--border)',
          borderRadius: 10, color: 'var(--muted)', fontSize: 12, textAlign: 'center',
        }}>
          没有运动员在 {currentDate} 录入此指标
        </div>
      );
    }

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
        <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              Leaderboard
            </span>
            <SampleSizeChip n={rows.length} total={athletes.length}/>
          </div>
          <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>
            {metric.label} · 最近 {currentDate} · {metric.dir === 'lower' ? '低值在前' : '高值在前'}
          </span>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column' }}>
          {rows.map((row, i) => (
            <LeaderboardRow key={row.athlete.id}
              rank={i + 1} row={row} metric={metric} swc={swc}
              onClick={() => onSelectAthlete && onSelectAthlete(row.athlete.id)}/>
          ))}
        </div>
      </div>
    );
  }

  function LeaderboardRow({ rank, row, metric, swc, onClick }) {
    const { athlete, value, delta, hist } = row;
    const improved = delta == null ? null : (metric.dir === 'lower' ? delta < 0 : delta > 0);
    const dColor = delta == null ? 'var(--muted)' : improved ? 'var(--pos)' : 'var(--neg)';
    const dArrow = delta == null ? '' : delta > 0 ? '▲' : delta < 0 ? '▼' : '·';
    const swcDelta = (delta != null && swc != null && swc > 0) ? delta / swc : null;
    const sigChip = swcDelta != null && Math.abs(swcDelta) >= 0.6;

    // Sparkline
    const sW = 60, sH = 20;
    let pathD = '';
    if (hist.length > 1) {
      const ys = hist.map(p => p.y);
      let yMin = Math.min(...ys), yMax = Math.max(...ys);
      if (yMin === yMax) { yMin -= 1; yMax += 1; }
      pathD = hist.map((p, idx) => {
        const x = (idx / (hist.length - 1)) * sW;
        const y = sH - ((p.y - yMin) / (yMax - yMin)) * sH;
        return (idx === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1);
      }).join('');
    }

    return (
      <div onClick={onClick} style={{
        display: 'grid',
        gridTemplateColumns: '32px 1fr 80px 80px 70px 30px',
        gap: 12, padding: '8px 14px',
        borderBottom: '1px solid rgba(15,23,42,.05)',
        cursor: onClick ? 'pointer' : 'default',
        alignItems: 'center',
      }}
        onMouseEnter={e => { if (onClick) e.currentTarget.style.background = 'rgba(15,23,42,.04)'; }}
        onMouseLeave={e => { if (onClick) e.currentTarget.style.background = 'transparent'; }}>
        <span className="mono" style={{ fontSize: 13, fontWeight: 600, color: rank <= 3 ? 'var(--pos)' : 'var(--muted)', textAlign: 'center' }}>
          #{rank}
        </span>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 12.5, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {athlete.name}
          </div>
          <div style={{ fontSize: 10, color: 'var(--muted)' }}>{athlete.position}</div>
        </div>
        <span className="mono" style={{ fontSize: 13, color: 'var(--text)', textAlign: 'right' }}>
          {fmt(value)}<span style={{ color: 'var(--muted-2)', fontSize: 10, marginLeft: 3 }}>{metric.unit}</span>
        </span>
        <span className="mono" style={{ fontSize: 11, color: dColor, textAlign: 'right' }}>
          {delta != null ? `${dArrow}${fmt(Math.abs(delta))}` : '—'}
          {sigChip && (
            <span style={{
              marginLeft: 4, fontSize: 8, color: dColor, fontWeight: 700,
              border: `1px solid ${dColor}55`, padding: '0 3px', borderRadius: 2,
            }} title={`${swcDelta.toFixed(2)}× SWC`}>
              {Math.abs(swcDelta) >= 1.2 ? 'L' : 'M'}
            </span>
          )}
        </span>
        {pathD ? (
          <svg width={sW} height={sH} style={{ display: 'block' }}>
            <path d={pathD} fill="none" stroke="var(--accent-2)" strokeWidth="1.2" strokeOpacity="0.8"/>
          </svg>
        ) : <span/>}
        <span style={{ color: 'var(--muted-2)', fontSize: 11 }}>›</span>
      </div>
    );
  }

  // ── Sample-size chip ──────────────────────────────────────────────────────
  // Used across stats components to surface low-n warnings.
  function SampleSizeChip({ n, total }) {
    const sev = n < 5 ? 'risk' : n < 10 ? 'warn' : 'ok';
    const color = sev === 'risk' ? 'var(--neg)' : sev === 'warn' ? 'var(--warn)' : 'var(--muted)';
    const bg    = sev === 'risk' ? 'rgba(248,113,113,.12)' : sev === 'warn' ? 'rgba(251,191,36,.10)' : 'transparent';
    const border= sev === 'risk' ? 'rgba(248,113,113,.35)' : sev === 'warn' ? 'rgba(251,191,36,.30)' : 'var(--border)';
    const hint = sev === 'risk' ? '样本量 <5，统计推断不可靠' :
                 sev === 'warn' ? '样本量 <10，统计推断需谨慎' :
                                  '样本量充足';
    return (
      <span title={hint} style={{
        display: 'inline-flex', alignItems: 'center', gap: 4,
        padding: '1px 6px', borderRadius: 3,
        background: bg, border: `1px solid ${border}`,
        fontSize: 10, color, fontFamily: 'var(--font-mono)', fontWeight: 600,
      }}>
        {sev === 'risk' ? '⚠ n=' : sev === 'warn' ? '◐ n=' : 'n='}{n}
        {total != null && total !== n && (
          <span style={{ color: 'var(--muted-2)', fontWeight: 400 }}>/{total}</span>
        )}
      </span>
    );
  }

  // ── Bootstrap 95% CI on mean ───────────────────────────────────────────────
  // Resampling with replacement; B=400 is plenty for visual CI bars on a feed.
  function bootstrapMeanCI(values, B = 400, alpha = 0.05) {
    if (!values || values.length < 2) return null;
    const n = values.length;
    const means = new Array(B);
    for (let b = 0; b < B; b++) {
      let s = 0;
      for (let i = 0; i < n; i++) s += values[(Math.random() * n) | 0];
      means[b] = s / n;
    }
    means.sort((a, b) => a - b);
    return {
      lo: means[Math.floor((alpha / 2) * B)],
      hi: means[Math.floor((1 - alpha / 2) * B)],
    };
  }

  // ── CohortMatrix ───────────────────────────────────────────────────────────
  // Rows = athletes (filtered cohort), columns = configurable metric set.
  // Cells = Z-score vs the cohort mean, color-coded. Sortable by any column.
  // The single most information-dense view for multi-athlete multi-metric
  // exploration — replaces what 4 separate per-metric leaderboards would show.
  function CohortMatrix({ athletes, groups, currentDate, squadStats, onSelectAthlete, defaultMetricIds, onSelectMetric }) {
    const D = window.DASHBOARD_DATA;
    const allMetrics = groups.flatMap(g => g.metrics.map(m => ({ ...m, group: g.label, accent: g.accent })));
    const defaults = defaultMetricIds && defaultMetricIds.length
      ? defaultMetricIds.filter(id => allMetrics.some(m => m.id === id))
      : ['jumpHeight', 'rsiMod', 'peakPower', 'rfd', 'sprint10m', 'sprint30m'].filter(id => allMetrics.some(m => m.id === id));

    const [columnIds, setColumnIds] = useState(defaults.slice(0, 8));
    const [sort, setSort] = useState({ key: '__name', dir: 'asc' });
    const [pickerOpen, setPickerOpen] = useState(false);

    const cols = columnIds.map(id => allMetrics.find(m => m.id === id)).filter(Boolean);

    // Precompute cohort-wide stats per column (mean / sd) so we can show
    // Z-scores referenced to the *cohort* (not the global squad), which is
    // what researchers actually want when slicing by position or age.
    const colStats = useMemo(() => {
      const out = {};
      cols.forEach(c => {
        const vals = athletes
          .map(a => a.seasons?.[currentDate]?.[c.id])
          .filter(v => v != null && isFinite(v));
        if (vals.length >= 2) {
          const m = vals.reduce((s, v) => s + v, 0) / vals.length;
          const sd = D ? D.stddev(vals) : 0;
          out[c.id] = { mean: m, sd, n: vals.length, total: athletes.length };
        } else if (vals.length === 1) {
          out[c.id] = { mean: vals[0], sd: 0, n: 1, total: athletes.length };
        } else {
          out[c.id] = { n: 0, total: athletes.length };
        }
      });
      return out;
    }, [athletes, cols, currentDate]);

    // Compute the matrix once; each cell carries raw value + z for sorting/coloring
    const matrix = useMemo(() => {
      return athletes.map(a => {
        const cells = {};
        cols.forEach(c => {
          const v = a.seasons?.[currentDate]?.[c.id];
          if (v == null || !isFinite(v)) {
            cells[c.id] = { value: null, z: null };
            return;
          }
          const cs = colStats[c.id];
          let z = null;
          if (cs && cs.sd > 0) {
            z = (v - cs.mean) / cs.sd;
            if (c.dir === 'lower') z = -z;  // higher z always = "better"
          }
          cells[c.id] = { value: v, z };
        });
        return { athlete: a, cells };
      });
    }, [athletes, cols, currentDate, colStats]);

    const sortedRows = useMemo(() => {
      const rows = [...matrix];
      if (sort.key === '__name') {
        rows.sort((a, b) => a.athlete.name.localeCompare(b.athlete.name));
      } else {
        rows.sort((a, b) => {
          const av = a.cells[sort.key]?.value, bv = b.cells[sort.key]?.value;
          if (av == null && bv == null) return 0;
          if (av == null) return 1;
          if (bv == null) return -1;
          return av - bv;
        });
      }
      if (sort.dir === 'desc') rows.reverse();
      return rows;
    }, [matrix, sort]);

    const toggleSort = (key) => setSort(s =>
      s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' }
    );

    const colorForZ = (z) => {
      if (z == null) return 'transparent';
      const mult = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--heat-mult')) || 0.6;
      const a = Math.min(Math.abs(z) / 2, 1);  // saturate at |z|=2
      return z >= 0
        ? `rgba(16,185,129,${a * mult})`
        : `rgba(239,68,68,${a * mult})`;
    };

    const toggleColumn = (id) => {
      setColumnIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id].slice(0, 10));
    };

    const colW = 88, nameW = 140;

    if (!athletes.length) {
      return (
        <div style={{
          padding: 16, background: 'var(--panel)', border: '1px dashed var(--border)',
          borderRadius: 10, color: 'var(--muted)', fontSize: 12, textAlign: 'center',
        }}>
          当前筛选下没有运动员
        </div>
      );
    }

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
        {/* Header */}
        <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              Athletes × Metrics
            </span>
            <span style={{ fontSize: 10, color: 'var(--muted-2)' }}>
              Z-score 相对队内均值 · 列宽固定 · 点击列名排序
            </span>
          </div>
          <button onClick={() => setPickerOpen(o => !o)} style={{
            fontSize: 10.5, padding: '3px 8px', borderRadius: 5, cursor: 'pointer',
            background: pickerOpen ? 'rgba(59,130,246,.08)' : 'transparent',
            border: `1px solid ${pickerOpen ? 'rgba(59,130,246,.3)' : 'var(--border)'}`,
            color: pickerOpen ? 'var(--accent-2)' : 'var(--muted)', fontFamily: 'var(--font-sans)',
          }}>⚙ 选列 ({columnIds.length})</button>
        </div>

        {/* Column picker */}
        {pickerOpen && (
          <div style={{ padding: '10px 14px', background: 'var(--bg)', borderBottom: '1px solid var(--border)' }}>
            {groups.map(g => (
              <div key={g.id} style={{ marginBottom: 8 }}>
                <div style={{ fontSize: 9, color: g.accent || 'var(--muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.06em', marginBottom: 4 }}>{g.label}</div>
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
                  {g.metrics.map(m => {
                    const checked = columnIds.includes(m.id);
                    return (
                      <button key={m.id} onClick={() => toggleColumn(m.id)} style={{
                        fontSize: 10.5, padding: '2px 7px', borderRadius: 4, cursor: 'pointer',
                        background: checked ? 'rgba(59,130,246,.10)' : 'var(--panel-2)',
                        border: `1px solid ${checked ? 'rgba(59,130,246,.3)' : 'var(--border)'}`,
                        color: checked ? 'var(--accent-2)' : 'var(--muted)', fontFamily: 'var(--font-sans)',
                      }}>
                        {m.label}
                      </button>
                    );
                  })}
                </div>
              </div>
            ))}
            <div style={{ fontSize: 10, color: 'var(--muted-2)', marginTop: 4 }}>最多 10 列，超出会被截断</div>
          </div>
        )}

        {/* Matrix */}
        <div style={{ overflowX: 'auto' }}>
          <div style={{ minWidth: 'max-content' }}>
            {/* Header row */}
            <div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
              <div
                onClick={() => toggleSort('__name')}
                style={{
                  width: nameW, minWidth: nameW, padding: '8px 12px',
                  fontSize: 11, fontWeight: 600, color: 'var(--muted)', cursor: 'pointer',
                  display: 'flex', alignItems: 'center', gap: 4,
                }}>
                Athlete {sort.key === '__name' && <span style={{ fontSize: 8 }}>{sort.dir === 'asc' ? '▲' : '▼'}</span>}
              </div>
              {cols.map(c => {
                const cs = colStats[c.id];
                return (
                  <div key={c.id}
                    onClick={() => toggleSort(c.id)}
                    style={{
                      width: colW, minWidth: colW, padding: '6px 8px',
                      fontSize: 10.5, color: c.accent || 'var(--muted)', fontWeight: 600,
                      textAlign: 'right', cursor: 'pointer',
                      borderLeft: '1px solid rgba(15,23,42,.05)',
                      display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2,
                    }} title={c.label + (c.unit ? ` (${c.unit})` : '')}>
                    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, whiteSpace: 'nowrap' }}>
                      <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: colW - 30 }}>{c.label}</span>
                      {sort.key === c.id && <span style={{ fontSize: 7 }}>{sort.dir === 'asc' ? '▲' : '▼'}</span>}
                    </div>
                    {cs && cs.n >= 1 && <SampleSizeChip n={cs.n} total={cs.total}/>}
                  </div>
                );
              })}
            </div>

            {/* Rows */}
            {sortedRows.map((row, ri) => (
              <div key={row.athlete.id} style={{
                display: 'flex',
                borderBottom: '1px solid rgba(15,23,42,.05)',
                background: ri % 2 === 0 ? 'transparent' : 'rgba(15,23,42,.025)',
              }}>
                <div
                  onClick={() => onSelectAthlete && onSelectAthlete(row.athlete.id)}
                  style={{
                    width: nameW, minWidth: nameW, padding: '8px 12px',
                    fontSize: 12, color: 'var(--text)', cursor: onSelectAthlete ? 'pointer' : 'default',
                    display: 'flex', flexDirection: 'column', justifyContent: 'center',
                  }}>
                  <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.athlete.name}</span>
                  <span style={{ fontSize: 10, color: 'var(--muted)' }}>{row.athlete.position}</span>
                </div>
                {cols.map(c => {
                  const cell = row.cells[c.id];
                  return (
                    <div key={c.id} style={{
                      width: colW, minWidth: colW, padding: '8px 8px',
                      borderLeft: '1px solid rgba(15,23,42,.05)',
                      background: colorForZ(cell.z),
                      textAlign: 'right', fontFamily: 'var(--font-mono)',
                      fontSize: 11.5, color: cell.value == null ? 'var(--muted-2)' : 'var(--text)',
                      opacity: cell.value == null ? 0.35 : 1,
                    }} title={cell.value != null && cell.z != null
                              ? `${c.label}: ${cell.value} ${c.unit || ''}\nZ = ${cell.z.toFixed(2)}σ`
                              : (cell.value != null ? `${c.label}: ${cell.value}` : '无数据')}>
                      {cell.value != null ? fmt(cell.value) : '—'}
                      {cell.z != null && (
                        <div style={{ fontSize: 9, color: cell.z >= 0 ? 'rgba(52,211,153,.7)' : 'rgba(248,113,113,.7)', marginTop: 1 }}>
                          {cell.z >= 0 ? '+' : ''}{cell.z.toFixed(1)}σ
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            ))}
          </div>
        </div>

        {/* Legend */}
        <div style={{ padding: '8px 14px', borderTop: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', display: 'flex', flexWrap: 'wrap', gap: 14 }}>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(52,211,153,.35)', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }}/>+2σ 优于队内</span>
          <span><span style={{ display: 'inline-block', width: 10, height: 10, background: 'rgba(248,113,113,.35)', borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }}/>-2σ 落后队内</span>
          <span style={{ marginLeft: 'auto', fontStyle: 'italic' }}>点击运动员行 → 进入个人页 · 列名点击排序</span>
        </div>
      </div>
    );
  }

  // ── CorrelationMatrix ──────────────────────────────────────────────────────
  // Pearson r heatmap (lower triangle) across selected metrics.
  // For each pair, pairs (athlete, season) where BOTH metrics have values are
  // used — gives more pairwise observations than strict complete-case at the
  // matrix level. Hover any cell to see r, n, and a mini-scatter preview.
  function CorrelationMatrix({ athletes, groups, seasons, defaultMetricIds }) {
    const allMetrics = groups.flatMap(g => g.metrics.map(m => ({ ...m, group: g.label, accent: g.accent })));
    const defaults = (defaultMetricIds && defaultMetricIds.length
      ? defaultMetricIds.filter(id => allMetrics.some(m => m.id === id))
      : ['jumpHeight', 'rsiMod', 'peakPower', 'rfd', 'sprint10m', 'sprint30m']
        .filter(id => allMetrics.some(m => m.id === id))).slice(0, 10);

    const [metricIds, setMetricIds] = useState(defaults);
    const [pickerOpen, setPickerOpen] = useState(false);
    const [hoverCell, setHoverCell] = useState(null);

    const cols = metricIds.map(id => allMetrics.find(m => m.id === id)).filter(Boolean);

    // Pearson r + complete-case pairs for each pair of metrics
    const matrix = useMemo(() => {
      const out = {};
      for (let i = 0; i < cols.length; i++) {
        for (let j = 0; j <= i; j++) {
          const a = cols[i], b = cols[j];
          const pairs = [];
          athletes.forEach(ath => {
            seasons.forEach(s => {
              const va = ath.seasons?.[s]?.[a.id];
              const vb = ath.seasons?.[s]?.[b.id];
              if (va != null && vb != null && isFinite(va) && isFinite(vb)) {
                pairs.push([va, vb]);
              }
            });
          });
          if (pairs.length < 3) { out[`${a.id}:${b.id}`] = { r: null, n: pairs.length, pairs }; continue; }
          const n = pairs.length;
          let mx = 0, my = 0;
          for (const [x, y] of pairs) { mx += x; my += y; }
          mx /= n; my /= n;
          let num = 0, dx2 = 0, dy2 = 0;
          for (const [x, y] of pairs) {
            const dx = x - mx, dy = y - my;
            num += dx * dy; dx2 += dx * dx; dy2 += dy * dy;
          }
          const r = (dx2 > 0 && dy2 > 0) ? num / Math.sqrt(dx2 * dy2) : null;
          // direction-correct: invert if one metric is "lower better"
          const signFlip = (a.dir === 'lower') !== (b.dir === 'lower');
          out[`${a.id}:${b.id}`] = { r: r != null && signFlip ? -r : r, rawR: r, n, pairs };
        }
      }
      return out;
    }, [athletes, cols, seasons]);

    const cellW = 60, headerH = 90, nameW = 130;
    const totalW = nameW + cols.length * cellW;
    const totalH = headerH + cols.length * cellW + 8;

    const colorForR = (r) => {
      if (r == null) return 'transparent';
      const mult = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--heat-corr-mult')) || 0.7;
      const a = Math.min(Math.abs(r), 1) * mult;
      return r >= 0
        ? `rgba(16,185,129,${a})`
        : `rgba(239,68,68,${a})`;
    };

    const toggleColumn = (id) => {
      setMetricIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id].slice(0, 10));
    };

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
        {/* Header */}
        <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
          <div>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>Correlation Matrix · Pearson r</span>
            <span style={{ fontSize: 10, color: 'var(--muted-2)', marginLeft: 8 }}>
              下三角 · 颜色饱和度 = |r| · 绿=正相关，红=负相关 · 已按方向校正
            </span>
          </div>
          <button onClick={() => setPickerOpen(o => !o)} style={{
            fontSize: 10.5, padding: '3px 8px', borderRadius: 5, cursor: 'pointer',
            background: pickerOpen ? 'rgba(59,130,246,.08)' : 'transparent',
            border: `1px solid ${pickerOpen ? 'rgba(59,130,246,.3)' : 'var(--border)'}`,
            color: pickerOpen ? 'var(--accent-2)' : 'var(--muted)', fontFamily: 'var(--font-sans)',
          }}>⚙ 选指标 ({metricIds.length})</button>
        </div>
        {pickerOpen && (
          <div style={{ padding: '10px 14px', background: 'var(--bg)', borderBottom: '1px solid var(--border)' }}>
            {groups.map(g => (
              <div key={g.id} style={{ marginBottom: 8 }}>
                <div style={{ fontSize: 9, color: g.accent || 'var(--muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.06em', marginBottom: 4 }}>{g.label}</div>
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
                  {g.metrics.map(m => {
                    const checked = metricIds.includes(m.id);
                    return (
                      <button key={m.id} onClick={() => toggleColumn(m.id)} style={{
                        fontSize: 10.5, padding: '2px 7px', borderRadius: 4, cursor: 'pointer',
                        background: checked ? 'rgba(59,130,246,.10)' : 'var(--panel-2)',
                        border: `1px solid ${checked ? 'rgba(59,130,246,.3)' : 'var(--border)'}`,
                        color: checked ? 'var(--accent-2)' : 'var(--muted)', fontFamily: 'var(--font-sans)',
                      }}>{m.label}</button>
                    );
                  })}
                </div>
              </div>
            ))}
            <div style={{ fontSize: 10, color: 'var(--muted-2)' }}>最多 10 个指标</div>
          </div>
        )}

        {/* Matrix render */}
        <div style={{ overflowX: 'auto', padding: '10px 0 14px' }}>
          <svg viewBox={`0 0 ${totalW} ${totalH}`} style={{ width: Math.min(totalW, 900), display: 'block' }}>
            {/* Column headers (rotated) */}
            {cols.map((c, j) => (
              <g key={c.id} transform={`translate(${nameW + j * cellW + cellW / 2}, ${headerH - 6})`}>
                <text transform="rotate(-45)" textAnchor="start" fontSize="10" fill={c.accent || 'var(--text-2)'} fontWeight="500">
                  {c.label}
                </text>
              </g>
            ))}
            {/* Row labels + cells */}
            {cols.map((rowM, i) => (
              <g key={rowM.id} transform={`translate(0, ${headerH + i * cellW})`}>
                <text x={nameW - 6} y={cellW / 2 + 4} textAnchor="end" fontSize="11" fill={rowM.accent || 'var(--text-2)'} fontWeight="500">
                  {rowM.label}
                </text>
                {cols.map((colM, j) => {
                  if (j > i) return null;  // upper triangle empty
                  const cell = matrix[`${rowM.id}:${colM.id}`];
                  const x = nameW + j * cellW;
                  const isDiag = i === j;
                  const isHover = hoverCell && hoverCell.i === i && hoverCell.j === j;
                  return (
                    <g key={colM.id}
                      onMouseEnter={() => setHoverCell({ i, j, rowM, colM, cell })}
                      onMouseLeave={() => setHoverCell(null)}
                      style={{ cursor: 'pointer' }}>
                      <rect x={x} y={2} width={cellW - 4} height={cellW - 4}
                        fill={isDiag ? 'rgba(15,23,42,.05)' : colorForR(cell.r)}
                        stroke={isHover ? 'var(--accent)' : 'rgba(15,23,42,.07)'}
                        strokeWidth={isHover ? 1.5 : 0.5} rx="2"/>
                      {!isDiag && cell.r != null && (
                        <text x={x + (cellW - 4) / 2} y={cellW / 2 + 4} textAnchor="middle"
                          fontSize="11" fontFamily="var(--font-mono)"
                          fill={Math.abs(cell.r) > 0.6 ? 'white' : 'var(--text-2)'} fontWeight="600">
                          {cell.r.toFixed(2)}
                        </text>
                      )}
                      {isDiag && (
                        <text x={x + (cellW - 4) / 2} y={cellW / 2 + 4} textAnchor="middle"
                          fontSize="11" fill="var(--muted-2)">—</text>
                      )}
                      {!isDiag && cell.r == null && (
                        <text x={x + (cellW - 4) / 2} y={cellW / 2 + 4} textAnchor="middle"
                          fontSize="9" fill="var(--muted-2)">n&lt;3</text>
                      )}
                    </g>
                  );
                })}
              </g>
            ))}
          </svg>
        </div>

        {/* Hover detail: mini-scatter for the focused pair */}
        {hoverCell && hoverCell.i !== hoverCell.j && hoverCell.cell.pairs.length >= 3 && (
          <div style={{
            padding: '12px 14px', borderTop: '1px solid var(--border)',
            display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, alignItems: 'center',
          }}>
            <div>
              <div style={{ fontSize: 12, color: 'var(--text)', fontWeight: 600 }}>
                {hoverCell.rowM.label} <span style={{ color: 'var(--muted-2)' }}>vs</span> {hoverCell.colM.label}
              </div>
              <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4, fontFamily: 'var(--font-mono)' }}>
                r = {hoverCell.cell.r.toFixed(3)}
                <span style={{ color: 'var(--muted-2)', marginLeft: 8 }}>(raw {hoverCell.cell.rawR.toFixed(3)})</span>
              </div>
              <div style={{ fontSize: 10.5, color: 'var(--muted)', marginTop: 2 }}>
                {hoverCell.cell.n} 个完整观测 ·
                {Math.abs(hoverCell.cell.r) > 0.7 ? ' 强相关' :
                 Math.abs(hoverCell.cell.r) > 0.4 ? ' 中等相关' :
                 Math.abs(hoverCell.cell.r) > 0.2 ? ' 弱相关' : ' 几乎无关'}
              </div>
            </div>
            <ScatterPreview pairs={hoverCell.cell.pairs} xLabel={hoverCell.colM.label} yLabel={hoverCell.rowM.label}/>
          </div>
        )}
      </div>
    );
  }

  function ScatterPreview({ pairs, xLabel, yLabel }) {
    if (!pairs || pairs.length < 2) return null;
    const W = 360, H = 110, ML = 26, MR = 8, MT = 4, MB = 18;
    const PW = W - ML - MR, PH = H - MT - MB;
    const xs = pairs.map(p => p[1]);  // x = column metric
    const ys = pairs.map(p => p[0]);  // y = row metric
    const xMin = Math.min(...xs), xMax = Math.max(...xs);
    const yMin = Math.min(...ys), yMax = Math.max(...ys);
    const xS = v => ML + (xMax === xMin ? PW / 2 : ((v - xMin) / (xMax - xMin)) * PW);
    const yS = v => MT + PH - (yMax === yMin ? PH / 2 : ((v - yMin) / (yMax - yMin)) * PH);
    return (
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: W, display: 'block' }}>
        <line x1={ML} y1={MT + PH} x2={ML + PW} y2={MT + PH} stroke="rgba(15,23,42,.12)"/>
        <line x1={ML} y1={MT} x2={ML} y2={MT + PH} stroke="rgba(15,23,42,.12)"/>
        {pairs.map((p, i) => (
          <circle key={i} cx={xS(p[1])} cy={yS(p[0])} r="2.5" fill="var(--accent-2)" fillOpacity="0.55"/>
        ))}
        <text x={ML + PW / 2} y={H - 4} textAnchor="middle" fontSize="8" fill="var(--muted)">{xLabel}</text>
        <text x={6} y={MT + PH / 2} textAnchor="middle" fontSize="8" fill="var(--muted)" transform={`rotate(-90,6,${MT + PH / 2})`}>{yLabel}</text>
      </svg>
    );
  }

  // ── Annotations ────────────────────────────────────────────────────────────
  // athlete.annotations: [{ id, date, label, category, note }]
  // Categories: 'injury' (red), 'intervention' (blue), 'competition' (gold), 'other' (gray)
  const ANNOTATION_CATEGORIES = {
    injury:       { label: '伤病/康复', color: '#f87171', icon: '✚' },
    intervention: { label: '训练干预', color: '#60a5fa', icon: '◆' },
    competition:  { label: '比赛',     color: '#fbbf24', icon: '★' },
    other:        { label: '其他',     color: '#94a3b8', icon: '●' },
  };

  // Renders annotation flags inside a chart's plot area. xScale maps date->px.
  // hostHeight is the SVG y-range (MT..MT+PH).
  function AnnotationFlags({ annotations, xScale, hostTop, hostBottom, onClick }) {
    if (!annotations || !annotations.length) return null;
    return (
      <g>
        {annotations.map(a => {
          const x = xScale(a.date);
          if (x == null || !isFinite(x)) return null;
          const cat = ANNOTATION_CATEGORIES[a.category] || ANNOTATION_CATEGORIES.other;
          return (
            <g key={a.id} style={{ cursor: onClick ? 'pointer' : 'default' }}
              onClick={() => onClick && onClick(a)}>
              <line x1={x} y1={hostTop} x2={x} y2={hostBottom}
                stroke={cat.color} strokeWidth="1.2" strokeDasharray="4 3" strokeOpacity="0.65"/>
              <g transform={`translate(${x}, ${hostTop - 4})`}>
                <rect x={-8} y={-12} width={16} height={14} rx={3}
                  fill={cat.color} fillOpacity="0.85" stroke="var(--bg)" strokeWidth="1"/>
                <text x={0} y={-2} textAnchor="middle" fontSize="9" fontWeight="700" fill="white">
                  {cat.icon}
                </text>
                <title>{`${a.date} · ${cat.label}\n${a.label}${a.note ? '\n' + a.note : ''}`}</title>
              </g>
            </g>
          );
        })}
      </g>
    );
  }

  // ── AnnotationManager ─────────────────────────────────────────────────────
  // Inline panel for adding / removing annotations on an athlete's timeline.
  function AnnotationManager({ athlete, onAthleteChange }) {
    const [open, setOpen] = useState(false);
    const [newDate, setNewDate] = useState(() => new Date().toISOString().slice(0, 10));
    const [newLabel, setNewLabel] = useState('');
    const [newCategory, setNewCategory] = useState('intervention');
    const [newNote, setNewNote] = useState('');

    const annotations = (athlete.annotations || []).slice().sort((a, b) => new Date(b.date) - new Date(a.date));

    const addAnnotation = () => {
      if (!newLabel.trim()) return;
      const next = {
        ...athlete,
        annotations: [...(athlete.annotations || []), {
          id: 'an_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
          date: newDate, label: newLabel.trim(), category: newCategory, note: newNote.trim() || undefined,
        }],
      };
      onAthleteChange(next);
      setNewLabel(''); setNewNote('');
    };
    const removeAnnotation = (id) => {
      onAthleteChange({ ...athlete, annotations: (athlete.annotations || []).filter(a => a.id !== id) });
    };

    return (
      <div style={{ background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
        <button onClick={() => setOpen(o => !o)} style={{
          width: '100%', background: 'transparent', border: 'none', cursor: 'pointer',
          padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          fontFamily: 'var(--font-sans)', color: 'var(--text-2)', textAlign: 'left',
        }}>
          <div>
            <span style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.06em', fontWeight: 600 }}>
              事件标注 (Timeline annotations)
            </span>
            <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--muted-2)' }}>
              {annotations.length} 条 · 在所有趋势图上显示
            </span>
          </div>
          <span style={{ fontSize: 10, color: 'var(--muted)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>▼</span>
        </button>
        {open && (
          <div style={{ borderTop: '1px solid var(--border)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 12 }}>
            {/* Existing annotations */}
            {annotations.length > 0 && (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                {annotations.map(a => {
                  const cat = ANNOTATION_CATEGORIES[a.category] || ANNOTATION_CATEGORIES.other;
                  return (
                    <div key={a.id} style={{
                      display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px',
                      background: 'var(--panel-2)', borderRadius: 6, border: '1px solid var(--border)',
                      borderLeft: `3px solid ${cat.color}`,
                    }}>
                      <span style={{ color: cat.color, fontSize: 13 }}>{cat.icon}</span>
                      <span className="mono" style={{ fontSize: 11, color: 'var(--muted)', minWidth: 90 }}>{a.date}</span>
                      <span style={{ fontSize: 12, color: 'var(--text-2)', fontWeight: 500 }}>{a.label}</span>
                      {a.note && <span style={{ fontSize: 11, color: 'var(--muted)', fontStyle: 'italic' }}>· {a.note}</span>}
                      <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted-2)' }}>{cat.label}</span>
                      <button onClick={() => removeAnnotation(a.id)} style={{
                        background: 'none', border: 'none', color: 'var(--muted-2)', cursor: 'pointer',
                        fontSize: 12, padding: 0,
                      }} title="删除">✕</button>
                    </div>
                  );
                })}
              </div>
            )}

            {/* Add form */}
            <div style={{
              display: 'grid', gridTemplateColumns: '130px 130px 1fr 1fr auto', gap: 8, alignItems: 'center',
              padding: '8px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px dashed var(--border)',
            }}>
              <input type="date" value={newDate} onChange={e => setNewDate(e.target.value)}
                style={{ background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 4, padding: '4px 8px', color: 'var(--text)', fontSize: 11, fontFamily: 'var(--font-sans)' }}/>
              <select value={newCategory} onChange={e => setNewCategory(e.target.value)}
                style={{ background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 4, padding: '4px 8px', color: 'var(--text)', fontSize: 11, fontFamily: 'var(--font-sans)', cursor: 'pointer' }}>
                {Object.entries(ANNOTATION_CATEGORIES).map(([id, c]) => (
                  <option key={id} value={id}>{c.icon} {c.label}</option>
                ))}
              </select>
              <input type="text" value={newLabel} onChange={e => setNewLabel(e.target.value)}
                placeholder="事件标题（如：髌腱康复完成）"
                style={{ background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 4, padding: '4px 8px', color: 'var(--text)', fontSize: 11, fontFamily: 'var(--font-sans)' }}/>
              <input type="text" value={newNote} onChange={e => setNewNote(e.target.value)}
                placeholder="备注（可选）"
                style={{ background: 'var(--panel-hi)', border: '1px solid var(--border)', borderRadius: 4, padding: '4px 8px', color: 'var(--text)', fontSize: 11, fontFamily: 'var(--font-sans)' }}/>
              <button className="btn primary" onClick={addAnnotation} disabled={!newLabel.trim()}
                style={{ fontSize: 11, padding: '4px 12px', opacity: newLabel.trim() ? 1 : 0.4 }}>
                + 添加
              </button>
            </div>
          </div>
        )}
      </div>
    );
  }

  // ── Public exports ─────────────────────────────────────────────────────────
  Object.assign(window, {
    ModeToggle, InsightFeed, HeadlineTilesRow, SessionDeltaTable,
    PerMetricSelector, PerMetricDistribution, PerMetricLeaderboard,
    CohortMatrix, SampleSizeChip, bootstrapMeanCI,
    AnnotationFlags, AnnotationManager, ANNOTATION_CATEGORIES,
    CorrelationMatrix,
    // MB-3: reused by the personal-page Trend module's catalog-backed picker so
    // the trends picker and the status-board picker are visually identical.
    MetricCatalogPicker,
  });
})();
