// LMCA score-vs-release-date chart.
// Reads window.LMCA_RESULTS. A least-squares trend line is drawn with a 95% CI band.
// Points that were state-of-the-art at their release date are labelled (de-collided).
// Per-point 95% CI shows as an error bar ONLY on hover, plus the interval in the tooltip.
// Lab visibility is a single filter: the legend (rendered above the chart as a
// "Labs" filter) holds the set of visible labs, defaulting to the frontier
// top-3; the Labs chips (Frontier / All) are presets over that same set, and
// the legend toggles individual labs.
const { useState: useTcState } = React;

const FRONTIER_LABS = new Set(["OpenAI", "Anthropic", "Google DeepMind"]);

// Per-model hover footnotes (keyed by display name), mirroring dtbench-chart.jsx.
const TC_NOTES = {
  "Claude Fable 5": "A small number of refusals were filled using Opus 5 data.",
  "Claude Sonnet 5": "On less than 5% of data, Sonnet 5 on max effort persistently (15-20 times) ran out of tokens. Missing data in this case was filled using the model on extra high effort instead.",
};

const TC_DESK = {
  W: 1000, H: 460,
  ml: 64, mr: 24, mt: 28, mb: 52,
  vbX: -15,
};
// Phone frame. The SVG scales to fit its column, so the viewBox IS the type
// size: at ~350px wide the 1000-unit desktop box renders everything at 0.35x
// (a 12-unit tick label lands at ~4px), while this 520-unit box renders at
// ~0.67x — 1.9x larger — with no font rules changed. Portrait-ish for the same
// reason the social cards are 4:5: a taller frame at a fixed column width buys
// back the room a time series loses when you narrow it.
const TC_PHONE = {
  W: 520, H: 680,
  // ml only has to clear the tick numbers now: the plot is full-bleed on phone,
  // so this margin lands them in the page gutter, left of the axis line, which
  // then sits roughly on the text margin.
  ml: 28, mr: 35, mt: 26, mb: 54,
  // The tick numbers fill this margin, leaving nowhere for the rotated axis
  // title, so the viewBox starts left of zero and the title lives in that strip
  // — it costs a little scale rather than plot width. AXT_X 0 / vbX -15 is as
  // close to the numbers as the title goes: measured on painted pixels (not
  // getBoundingClientRect, which counts the unused descender depth that faces
  // the numbers after rotate(-90)), the ink gap is 1.5px, and one unit further
  // right the two runs touch.
  vbX: -15,
};
const TC_PHONE_MQ = "(max-width: 760px)";

function parseDate(s) {
  // "YYYY-MM-DD" (or "YYYY-MM") -> fractional month index
  const [y, m, d] = s.split("-").map(Number);
  return y * 12 + (m - 1) + ((d || 1) - 1) / 31;
}

const MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function fmtMonth(idx) {
  const i = Math.round(idx);
  const y = Math.floor(i / 12);
  const m = ((i % 12) + 12) % 12;
  return `${MONTH_ABBR[m]} '${String(y).slice(2)}`;
}

function shortName(n) {
  return n.replace(/^Claude\s+/, "").replace(/\s*\((preview|thinking)\)$/, "")
    // old number-first Claude names ("3 Opus", "3.5 Sonnet") -> tier-first
    // ("Opus 3", "Sonnet 3.5"), matching the newer "Opus 4"/"Opus 5" style.
    .replace(/^(\d+(?:\.\d+)?)\s+(Opus|Sonnet|Haiku)\b/, "$2 $1");
}

// least-squares fit + 95% CI-of-mean over a set of points; null if too few
function fitTrend(pts) {
  const n = pts.length;
  if (n < 3) return null;
  const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
  const xbar = xs.reduce((a, b) => a + b, 0) / n;
  const ybar = ys.reduce((a, b) => a + b, 0) / n;
  let Sxx = 0, Sxy = 0, Syy = 0;
  for (const p of pts) { Sxx += (p.x - xbar) ** 2; Sxy += (p.x - xbar) * (p.y - ybar); Syy += (p.y - ybar) ** 2; }
  if (Sxx === 0) return null;
  const slope = Sxy / Sxx;
  const intercept = ybar - slope * xbar;
  const yhat = (x) => intercept + slope * x;
  let sse = 0;
  for (const p of pts) sse += (p.y - yhat(p.x)) ** 2;
  const s = Math.sqrt(sse / (n - 2));
  const t = 1.96;
  const se = (x) => s * Math.sqrt(1 / n + ((x - xbar) ** 2) / Sxx);
  const r = Syy > 0 ? Sxy / Math.sqrt(Sxx * Syy) : 0;
  return { yhat, r, lo: (x) => yhat(x) - t * se(x), hi: (x) => yhat(x) + t * se(x) };
}

// indices (into pts) of points that were best-so-far at their release date
function findRecords(pts) {
  const dateMax = {};
  for (const p of pts) dateMax[p.released] = Math.max(dateMax[p.released] ?? -Infinity, p.y);
  const datesAsc = Object.keys(dateMax).sort((a, b) => parseDate(a) - parseDate(b));
  const records = [];
  let prevMax = -Infinity;
  for (const d of datesAsc) {
    if (dateMax[d] > prevMax) {
      const idx = pts.findIndex((p) => p.released === d && p.y === dateMax[d]);
      if (idx >= 0) records.push(idx);
    }
    prevMax = Math.max(prevMax, dateMax[d]);
  }
  // Suppress a record that a higher one supersedes within ~a week: a model that
  // led for only days (e.g. GPT-4.1, two days before o3) isn't a real milestone.
  const MIN_REIGN = 0.25; // months
  return records.filter((idx, i) => {
    const next = records[i + 1];
    return next == null || parseDate(pts[next].released) - parseDate(pts[idx].released) >= MIN_REIGN;
  });
}

function TrendChart({ orgColors, teal, source, caption, excludeOrgs, excludeModels }) {
  // Phone gets its own frame, tick step, dot size and label budget.
  const [isPhone, setIsPhone] = useTcState(
    () => typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia(TC_PHONE_MQ).matches : false);
  React.useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(TC_PHONE_MQ);
    const on = () => setIsPhone(mq.matches);
    on();
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => (mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on));
  }, []);
  const TC = isPhone ? TC_PHONE : TC_DESK;
  const DOT_R = isPhone ? 6 : 4;
  const AXT_X = isPhone ? 0 : 16;    // y-axis title, in the extended strip on phone
  const ttRef = React.useRef(null);
  const [ttFit, setTtFit] = useTcState({ flip: false, nudge: 0 });
  const excludeSet = new Set(excludeOrgs || []);
  const excludeModelSet = new Set(excludeModels || []);
  const [hover, setHover] = useTcState(null);
  const [dataset, setDataset] = useTcState("CRI"); // CRI | LMCA | ACCoRD | DTBench (cri source only)
  const [showAll, setShowAll] = useTcState(false); // false = SoTA-only; true = every model
  // Single source of truth for lab visibility: the set of visible labs.
  // Default = frontier top-3, so the default view matches the old Labs=Frontier.
  const [visibleLabs, setVisibleLabs] = useTcState(() => new Set(FRONTIER_LABS));

  const src = source === "cri" ? "cri" : "lmca";
  const isCri = src === "cri";
  // All dataset scores are precomputed + scaled in data.js (see the CRI post-process there):
  //   m.overall = weighted CRI; m.scores[...] = per-dataset 0-100 scores.
  const DS_KEY = {
    LMCA: "Argument evaluation (LMCA)",
    ACCoRD: "Consistency (ACCoRD)",
    DTBench: "Decision theory (DTBench)",
  };
  // All four toggles (CRI / LMCA / ACCoRD / DTBench) read the leaderboard data
  // (data/cri-scores.csv via data.js) so the same `sota` flag drives every view.
  // The full ACCoRD benchmark (incl. non-leaderboard models) lives on
  // accord.html; here ACCoRD shows the SoTA models that have an ACCoRD score.
  let raw;
  if (isCri) {
    raw = (window.MODELS || [])
      .map((m) => ({
        released: m.released,
        score: dataset === "CRI" ? m.overall : m.scores[DS_KEY[dataset]],
        ci: m.ci ? (dataset === "CRI" ? m.ci.overall : m.ci[DS_KEY[dataset]]) : null,
        name: m.name, org: m.org, partial: m.partial, sota: m.sota,
        star: window.criStarForView(m, dataset),
        coverageNote: m.coverageNote,
      }))
      .filter((p) => p.score != null);
  } else {
    raw = window.LMCA_RESULTS || [];
  }
  // Drop explicitly-excluded models, plus anything without a valid release date
  // — a dateless point's NaN x-coordinate would corrupt the regression + band.
  raw = raw.filter((p) => !excludeModelSet.has(p.name)
    && p.released && Number.isFinite(parseDate(p.released)));
  // SoTA: plot exactly the models hand-classified as state-of-the-art — a
  // frontier/flagship release for their lab at the time (the `sota` flag in
  // data/cri-scores.csv, from the two-pass research + verification sweep).
  // "Show all models" drops that filter; either way the explicit org exclusions
  // still apply.
  // Lab visibility is then a single filter over the model-filtered points: only
  // labs in `visibleLabs` are drawn. Every SoTA point is shown — same-lab
  // same-date variants (e.g. Grok 4.20 / 4.20 Multi-Agent) each get their own dot.
  const modelFiltered = raw.filter((p) => showAll || p.sota);
  const displayed = modelFiltered.filter((p) => visibleLabs.has(p.org));
  const pts = displayed.map((m) => ({
    x: parseDate(m.released), y: m.score, ci: m.ci,
    name: m.name, org: m.org, released: m.released, partial: m.partial, star: m.star,
    coverageNote: m.coverageNote, note: TC_NOTES[m.name],
  }));
  const title = isCri
    ? (dataset === "CRI" ? "CRI score over time" : dataset + " score over time")
    : "LMCA performance over time";
  const yAxisLabel = isCri
    ? (dataset === "CRI" ? "CRI score" : dataset + " score")
    : "% correlation with human scores";

  // x domain is toggle-independent: left edge = earliest of ALL plottable
  // points (every lab, both Models views), right edge = today — so no filter
  // state can rescale the axis, and new releases simply fill in the gap.
  // (If a point were ever dated in the future, the edge extends to keep it
  // on-plot.) Also sane when the visible set is empty.
  const allXs = raw.map((p) => parseDate(p.released));
  const nowD = new Date();
  const todayX = nowD.getFullYear() * 12 + nowD.getMonth() + (nowD.getDate() - 1) / 31;
  const minX = allXs.length ? Math.min(...allXs) : 0;
  const maxX = Math.max(todayX, ...(allXs.length ? allXs : [todayX]));
  const yMin = 0, yMax = isCri ? 104 : 92;
  const xPad = 1;
  const x0 = minX - xPad, x1 = maxX + xPad;

  const px = (x) => TC.ml + ((x - x0) / (x1 - x0)) * (TC.W - TC.ml - TC.mr);
  const py = (y) => TC.mt + (1 - (y - yMin) / (yMax - yMin)) * (TC.H - TC.mt - TC.mb);

  const trend = fitTrend(pts);
  let linePath = "", bandPath = "";
  if (trend) {
    const samples = [];
    const steps = 60;
    // Every dataset on this chart is scored out of 100, and the y-axis runs to
    // 104 purely for headroom — so cap the fitted line and its CI band at 100
    // rather than letting an extrapolated fit climb past an unreachable score.
    const capY = (v) => Math.min(v, 100);
    for (let i = 0; i <= steps; i++) {
      const x = x0 + (i / steps) * (x1 - x0);
      samples.push({ x, yc: capY(trend.yhat(x)), hi: capY(trend.hi(x)), lo: capY(trend.lo(x)) });
    }
    linePath = samples.map((s, i) => `${i ? "L" : "M"}${px(s.x).toFixed(1)},${py(s.yc).toFixed(1)}`).join(" ");
    bandPath =
      samples.map((s, i) => `${i ? "L" : "M"}${px(s.x).toFixed(1)},${py(s.hi).toFixed(1)}`).join(" ") + " " +
      samples.slice().reverse().map((s) => `L${px(s.x).toFixed(1)},${py(s.lo).toFixed(1)}`).join(" ") + " Z";
  }

  // record-holder labels, de-collided by pushing upward into empty space
  let recIdx = findRecords(pts);
  // The phone frame cannot carry ~14 stacked names. Keep the milestones that
  // actually move the frontier: always the first and the latest, then fill up
  // to PHONE_LABELS by largest score gain over the previously kept record, so
  // what survives is the biggest jumps rather than an arbitrary slice.
  const PHONE_LABELS = 7;
  if (isPhone && recIdx.length > PHONE_LABELS) {
    const keep = new Set([recIdx[0], recIdx[recIdx.length - 1]]);
    const gains = recIdx
      .map((idx, k) => ({ idx, gain: k === 0 ? Infinity : pts[idx].y - pts[recIdx[k - 1]].y }))
      .sort((a, b) => b.gain - a.gain);
    for (const g of gains) {
      if (keep.size >= PHONE_LABELS) break;
      keep.add(g.idx);
    }
    recIdx = recIdx.filter((i) => keep.has(i));
  }
  const labels = recIdx.map((i) => {
    const p = pts[i];
    return { p, x: px(p.x), anchorY: py(p.y), text: shortName(p.name) + (p.star ? "*" : "") };
  });
  const LGAP = isPhone ? 21 : 16;   // more air between stacked names on the narrow frame
  // Lift each label clear of any DOT it would sit on, before stacking them
  // against each other. The old pass de-collided labels with labels only, and on
  // the phone frame a name spans roughly twice the x-fraction it does on desktop,
  // so with Models=All and Labs=All the names landed on points.
  // .tc-label is 12px mono, whose advance is ~0.6em — hence the 0.3 half-width.
  const LAB_FS = 12, HALF_CH = LAB_FS * 0.3, CLEAR = DOT_R + 5;
  // Screen y of the fitted line at a given screen x. px is linear, so invert it.
  const lineYAt = (sx) => {
    const dataX = x0 + ((sx - TC.ml) / (TC.W - TC.ml - TC.mr)) * (x1 - x0);
    return py(Math.min(trend.yhat(dataX), 100));
  };
  for (const lp of labels) {
    const halfW = lp.text.length * HALF_CH + 2;
    let ly = lp.anchorY - (isPhone ? 16 : 14);
    for (const q of pts) {
      const qx = px(q.x), qy = py(q.y);
      if (Math.abs(qx - lp.x) > halfW + DOT_R) continue;   // not under the name
      if (qy > lp.anchorY + 1) continue;                    // below its own point
      if (qy > ly - LAB_FS && qy < ly + CLEAR) ly = qy - CLEAR;
    }
    // ...and clear of the trend line itself, which the dot pass ignored — "o1"
    // sat straight on it. The fit rises left to right, so its topmost point
    // across the name's width is at the right-hand end.
    if (trend) {
      const lineTop = Math.min(lineYAt(lp.x - halfW), lineYAt(lp.x + halfW));
      if (lineTop > ly - LAB_FS && lineTop < ly + CLEAR) ly = lineTop - CLEAR;
    }
    lp.ly = ly;
  }
  // Then label-vs-label. This used to push every label a fixed gap above the
  // previous one whatever their x, which both lifted well-separated names away
  // from their points for no reason and left genuinely overlapping ones with
  // only the minimum — two long names in the same corner ended up 11px apart and
  // read as squeezed. Now a label is only pushed above the ones whose x-range it
  // actually overlaps. Still upward-only, so the dot clearance above survives.
  labels.sort((a, b) => b.ly - a.ly); // bottom-most first
  const halfOf = (lp) => lp.text.length * HALF_CH + 2;
  const placed = [];
  for (const lp of labels) {
    for (const q of placed) {
      if (Math.abs(q.x - lp.x) > halfOf(lp) + halfOf(q)) continue; // clear of each other
      if (lp.ly > q.ly - LGAP) lp.ly = q.ly - LGAP;
    }
    placed.push(lp);
  }

  // axis ticks
  const yTicks = isCri ? [0, 20, 40, 60, 80, 100] : [0, 10, 20, 30, 40, 50, 60, 70, 80];
  const xTicks = [];
  // 3-monthly ticks crowd a 520-unit box; half-yearly on phone.
  const TICK_STEP = isPhone ? 6 : 3;
  for (let m = Math.ceil(x0 / TICK_STEP) * TICK_STEP; m <= x1; m += TICK_STEP) xTicks.push(m);

  // Estimated-ceiling reference line, per view. LMCA's ceiling is a correlation
  // of ~0.85 -> a score of 85; ACCoRD and DTBench ceilings are 100. The CRI's
  // joint ceiling is the weighted mean: 0.6*85 + 0.4*100 = 91.
  const CEILING = { CRI: 91, LMCA: 85, ACCoRD: 100, DTBench: 100 };
  const ceiling = isCri ? CEILING[dataset] : 85;

  const colorFor = (org) => (orgColors && orgColors[org]) || teal;
  // Legend order: major labs first, then the rest by window.ORGS order.
  const MAJOR_LABS = ["OpenAI", "Anthropic", "Google DeepMind", "GDM"];
  const orgRank = (o) => {
    const m = MAJOR_LABS.indexOf(o);
    if (m >= 0) return m;
    const i = window.ORGS.indexOf(o);
    return 100 + (i < 0 ? 900 : i);
  };
  // Legend lists every lab available under the current dataset + model filter,
  // regardless of visibility (hidden labs render hollow, click to re-show).
  const orgsPresent = Array.from(new Set(modelFiltered.map((p) => p.org)))
    .sort((a, b) => orgRank(a) - orgRank(b));
  // Every lab across ALL datasets (not just the current one), so the "All"
  // preset and per-lab toggles persist when you switch benchmarks — a lab with
  // no data in the current view stays in visibleLabs and reappears in one where
  // it does (e.g. Cohere/Reka under DTBench).
  const allOrgs = Array.from(new Set((window.MODELS || []).map((m) => m.org)));
  const toggleOrg = (org) => setVisibleLabs((v) => {
    const next = new Set(v);
    if (next.has(org)) next.delete(org); else next.add(org);
    return next;
  });
  // Presets: Frontier = FRONTIER_LABS ∩ available orgs; All = every org; None = ∅.
  const frontierAvail = orgsPresent.filter((o) => FRONTIER_LABS.has(o));
  const visibleAvail = orgsPresent.filter((o) => visibleLabs.has(o));
  const isFrontierView = visibleAvail.length === frontierAvail.length
    && frontierAvail.every((o) => visibleLabs.has(o));
  const capW = 4;
  const cy = TC.mt + (TC.H - TC.mt - TC.mb) / 2;
  const hoverP = hover != null && visibleLabs.has(hover.org) ? hover : null;
  // Tooltip side. The old rule flipped past a hard-coded 70% of the chart width,
  // which knows nothing about how wide the tooltip actually is, so it still ran
  // off the right on desktop. (It never fired anyway — the modifier rule sat
  // before .tc-tooltip in the stylesheet and lost on source order.) Measure the
  // rendered box and flip only when it genuinely will not fit. The percentages
  // divide by the full viewBox span, which is wider than TC.W on phone because
  // the y-axis title strip starts at TC.vbX. Decided from the UNFLIPPED
  // position, so it cannot oscillate.
  const vbW = TC.W - TC.vbX;
  const hoverLeftPct = hoverP ? ((px(hoverP.x) - TC.vbX) / vbW) * 100 : 0;
  const hoverTopPct = hoverP ? (py(hoverP.y) / TC.H) * 100 : 0;
  React.useLayoutEffect(() => {
    const el = ttRef.current, host = el && el.offsetParent;
    if (!el || !host) return;
    const w = el.offsetWidth, hw = host.clientWidth;
    const cx = (hoverLeftPct / 100) * hw;
    // Prefer the right of the point; flip left if it will not fit. If it fits on
    // neither side — a tooltip wider than the plot — flipping alone would push it
    // off the opposite edge, so clamp it back inside as well.
    const flip = cx + 16 + w > hw - 2;
    const natural = flip ? cx - 16 - w : cx + 16;
    let nudge = 0;
    if (natural < 2) nudge = 2 - natural;
    else if (natural + w > hw - 2) nudge = (hw - 2 - w) - natural;
    setTtFit((prev) => (prev.flip === flip && Math.abs(prev.nudge - nudge) < 0.5
      ? prev : { flip, nudge }));
  }, [hoverP, hoverLeftPct]);

  return (
    <section className="trend-wrap" id="trend">
      <div className="trend-head">
        <div>
          <div className="eyebrow">Trend</div>
          <div className="trend-title">{title}</div>
        </div>
      </div>

      {isCri && (
        <div style={{ display: "flex", gap: "16px 20px", flexWrap: "wrap", margin: "0 auto 20px" }}>
          <div className="filter-group">
            <div className="filter-label">Dataset</div>
            <div className="filter-opts">
              {["CRI", "LMCA", "ACCoRD", "DTBench"].map((d) => (
                <button key={d} type="button" onClick={() => setDataset(d)}
                  className={"filter-chip" + (dataset === d ? " is-active" : "")}>{d}</button>
              ))}
            </div>
          </div>
          <div className="filter-group">
            <div className="filter-label">Models</div>
            <div className="filter-opts">
              {[["SoTA", false], ["All", true]].map(([lbl, v]) => (
                <button key={"m" + lbl} type="button" onClick={() => setShowAll(v)}
                  className={"filter-chip" + (showAll === v ? " is-active" : "")}>{lbl}</button>
              ))}
            </div>
          </div>
          <div className="filter-group">
            <div className="filter-label">Labs</div>
            <div className="filter-opts">
              <button type="button" onClick={() => setVisibleLabs(new Set(FRONTIER_LABS))}
                className={"filter-chip" + (isFrontierView ? " is-active" : "")}>Frontier</button>
              <button type="button" onClick={() => setVisibleLabs(new Set(allOrgs))}
                className={"filter-chip" + (visibleAvail.length === orgsPresent.length ? " is-active" : "")}>All</button>
            </div>
          </div>
        </div>
      )}

      {/* Lab legend: the chips above are presets over the same visible-labs set;
          clicking a legend entry toggles that one lab (filled = visible, hollow
          = hidden). A custom mix simply leaves neither chip highlighted. */}
      <div className="trend-labs">
        <div className="trend-legend">
          {orgsPresent.map((org) => (
            <button
              key={org}
              type="button"
              className={"tc-leg" + (visibleLabs.has(org) ? "" : " is-off")}
              onClick={() => toggleOrg(org)}
              title={visibleLabs.has(org) ? "Hide " + org : "Show " + org}>
              <span className="tc-leg-dot" style={{ "--leg-c": colorFor(org) }} />
              {org}
            </button>
          ))}
        </div>
      </div>

      <div className="trend-plot">
        <svg viewBox={`${TC.vbX} 0 ${TC.W - TC.vbX} ${TC.H}`} className="trend-svg" preserveAspectRatio="xMidYMid meet">
          <defs>
            <clipPath id="tc-plot-clip">
              <rect x={TC.ml} y={TC.mt} width={TC.W - TC.ml - TC.mr} height={TC.H - TC.mt - TC.mb} />
            </clipPath>
          </defs>
          {/* gridlines + y labels */}
          {yTicks.map((v) => (
            <g key={`y${v}`}>
              <line x1={TC.ml} x2={TC.W - TC.mr} y1={py(v)} y2={py(v)} className="tc-grid" />
              <text x={TC.ml - (isPhone ? 6 : 12)} y={py(v)} className="tc-ylabel" dominantBaseline="middle" textAnchor="end">{v}</text>
            </g>
          ))}
          {/* x labels */}
          {xTicks.map((m) => (
            <g key={`x${m}`}>
              <line x1={px(m)} x2={px(m)} y1={TC.H - TC.mb} y2={TC.H - TC.mb + 6} className="tc-tick" />
              <text x={px(m)} y={TC.H - TC.mb + 22} className="tc-xlabel" textAnchor="middle">{fmtMonth(m)}</text>
            </g>
          ))}
          {/* axes baseline */}
          <line x1={TC.ml} x2={TC.W - TC.mr} y1={TC.H - TC.mb} y2={TC.H - TC.mb} className="tc-axis" />

          {/* estimated ceiling reference line (per view: LMCA 85, ACCoRD/DTBench
              100, CRI 91 = 0.6*85 + 0.4*100) */}
          {ceiling != null && <line x1={TC.ml} x2={TC.W - TC.mr} y1={py(ceiling)} y2={py(ceiling)} stroke="var(--ink-2, #3a3730)" strokeWidth="1.25" strokeDasharray="5 4" strokeOpacity="0.55" />}
          {ceiling != null && <text x={TC.ml + (TC.W - TC.ml - TC.mr) / 2} y={py(ceiling) - 7} className="tc-xlabel" textAnchor="middle" style={{ fontStyle: "italic", opacity: 0.7 }}>Estimated ceiling ({ceiling})</text>}

          {/* y-axis title */}
          <text className="tc-axis-title"
                transform={`rotate(-90 ${AXT_X} ${cy})`} x={AXT_X} y={cy} textAnchor="middle"
                style={isPhone ? { fontSize: "13px" } : undefined}>{yAxisLabel}</text>

          {/* trend CI band + trend line */}
          {trend && (
            <g clipPath="url(#tc-plot-clip)">
              <path d={bandPath} fill="var(--teal)" fillOpacity="0.10" stroke="none" />
              <path d={linePath} fill="none" stroke="var(--teal)" strokeWidth="2" />
            </g>
          )}

          {/* Pearson r of the currently plotted points — tracks every filter
              (Dataset, Models, Labs chips, and individual legend toggles).
              Sits just under the y=80 gridline at the left edge — an empty
              corner in every view (nothing released before ~Feb 2024 scores
              above 60). Pinned to a gridline rather than to the ceiling line so
              it doesn't jump when the Dataset toggle changes the ceiling. */}
          {trend && (
            <text
              x={TC.ml + 70}
              y={py(80) + 18}
              className="tc-xlabel" textAnchor="start"
              style={{ fontStyle: "italic", opacity: 0.7 }}>
              r = {trend.r.toFixed(2)}
            </text>
          )}

          {/* per-point CI error bar — only for the hovered point */}
          {hoverP && hoverP.ci != null && (() => {
            const p = hoverP;
            const c = colorFor(p.org);
            const xC = px(p.x), yHi = py(p.y + p.ci), yLo = py(p.y - p.ci);
            return (
              <g style={{ pointerEvents: "none" }}>
                <line x1={xC} x2={xC} y1={yHi} y2={yLo} stroke={c} strokeWidth="2" opacity="0.9" />
                <line x1={xC - capW} x2={xC + capW} y1={yHi} y2={yHi} stroke={c} strokeWidth="2" opacity="0.9" />
                <line x1={xC - capW} x2={xC + capW} y1={yLo} y2={yLo} stroke={c} strokeWidth="2" opacity="0.9" />
              </g>
            );
          })()}

          {/* record-holder labels with leaders */}
          {labels.map((lp, k) => (
            <g key={`lab${k}`} style={{ pointerEvents: "none" }}>
              <line x1={lp.x} y1={lp.anchorY - 5} x2={lp.x} y2={lp.ly + 3} className="tc-leader" />
              <text className="tc-label" x={lp.x} y={lp.ly} textAnchor="middle">{lp.text}</text>
            </g>
          ))}

          {/* points */}
          {pts.map((p, i) => (
            <circle
              key={p.name + p.released}
              cx={px(p.x)}
              cy={py(p.y)}
              r={hoverP === p ? DOT_R + 2 : DOT_R}
              fill={colorFor(p.org)}
              className="tc-pt"
              onMouseEnter={() => setHover(p)}
              onMouseLeave={() => setHover(null)}
            />
          ))}
        </svg>

        {hoverP && (() => {
          const p = hoverP;
          const leftPct = hoverLeftPct, topPct = hoverTopPct;
          const lo = (p.y - p.ci).toFixed(1), hi = (p.y + p.ci).toFixed(1);
          return (
            <div ref={ttRef}
                 className={"tc-tooltip" + (ttFit.flip ? " tc-tooltip--flip" : "")}
                 style={{ left: `${leftPct}%`, top: `${topPct}%`, "--tt-nudge": `${ttFit.nudge}px` }}>
              <div className="tc-tt-name">{p.name}{p.partial ? " · partial" : ""}</div>
              <div className="tc-tt-meta mono-small">{p.org} · {fmtMonth(p.x)}</div>
              <div className="tc-tt-score" style={{ color: colorFor(p.org) }}>{p.y.toFixed(1)}</div>
              {p.ci != null && <div className="tc-tt-ci mono-small">95% CI {lo}–{hi} (±{p.ci.toFixed(1)})</div>}
              {p.star && p.coverageNote && <div className="tc-tt-ci mono-small">Partial data: {p.coverageNote}</div>}
              {p.note && <div className="tc-tt-ci mono-small">{p.note}</div>}
            </div>
          );
        })()}
      </div>

      {/* One wrapper class for every caption branch — mixed classes made the
          caption jump vertically when toggling between views. */}
      {(() => {
        const base = showAll
          ? "The shaded band is the trend line's 95% Confidence Interval."
          : (caption && isFrontierView
              ? caption
              : "Each plotted model was the respective lab's most generally capable model at the time of release. The shaded band is the trend line's 95% Confidence Interval.");
        // Only the CRI/Overall and ACCoRD views star anything — criStarForView
        // (data.js:46) matches the view code against coverage_note, and GPT-4's
        // ACCoRD note is the only one in the data — so this is already absent
        // from the LMCA and DTBench views and names GPT-4 safely.
        const starNote = displayed.some((p) => p.star)
          ? " * Partial GPT-4 data: model refused to fully answer 18% of ACCoRD items."
          : "";
        return <p className="trend-figcaption mono-small">{base}{starNote}</p>;
      })()}
    </section>
  );
}

window.TrendChart = TrendChart;
