// Performance-vs-cost scatter (log cost axis) with a Pareto frontier, plus an
// optional "inference-time scaling" overlay that connects each multi-effort
// model's low→max points into a curve. Styled to match the other charts (reuses
// the .trend-* / .tc-* / .filter-* CSS). Reads window.MODELS_ALL from data.js,
// which carries the per-effort rows and per-response cost columns.
//
// Two configured wrappers are mounted on methodology.html:
//   window.LmcaCostChart    — LMCA score (y) vs. avg LMCA cost per response (x)
//   window.DtbenchCostChart — DTBench score (y) vs. avg DTBench cost per response (x)
const { useState: useCcState } = React;

const CC_LMCA = "Argument evaluation (LMCA)";
const CC_DT = "Decision theory (DTBench)";
// "off" is the reasoning-disabled floor of each ladder, so it sorts first.
// Shade order for a lab's scaling curves: the FIRST model gets the lab's own
// brand colour and each one after it is progressively lighter. Ordered strongest
// first (this is CRI descending as of 2026-09), so the lab's flagship reads as
// the lab's colour and the cheaper tiers fade out from it.
//
// Explicit rather than sorted by score, so a colour cannot swap between data
// pulls when two models cross — people learn "the dark green one is Astra".
// It was previously alphabetical, which is why GPT-5.6 Sol sat in the middle of
// the OpenAI ramp. A model not listed here sorts after the listed ones,
// alphabetically, and gets the lightest shades — so ADD NEW FLAGSHIPS HERE.
const CC_CURVE_RANK = [
  "GPT-6 Astra", "GPT-5.6 Sol", "GPT-5.6 Terra", "GPT-5.6 Luna",
  "Claude Fable 5.1", "Claude Opus 5", "Claude Fable 5", "Claude Sonnet 5",
  "Gemini 3.8 Flash", "Gemini 3.7 Flash", "Gemini 3.1 Pro",
];

const CC_EFFORT_ORDER = ["off", "low", "medium", "high", "xhigh", "max"];

// Tall frames: the scaling curves + frontier all live in the upper score band,
// so a taller plot gives them room to separate rather than pile up.
const CC_DESK = { W: 1000, H: 620, ml: 62, mr: 40, mt: 28, mb: 74, vbX: 0 };
// Phone frame, same reasoning as the other charts: the SVG scales to its column
// so the viewBox IS the type size — a ~520-unit box renders ~1.9x larger than
// the 1000-unit desktop one at the same on-screen width.
const CC_PHONE = { W: 520, H: 720, ml: 44, mr: 30, mt: 24, mb: 80, vbX: -19 };
const CC_PHONE_MQ = "(max-width: 760px)";

const log10 = (v) => Math.log(v) / Math.LN10;
// Curve endpoint labels are tight on space: drop the "Claude " prefix so the
// Anthropic models read as "Opus 5" / "Fable 5" / "Sonnet 5".
const ccShort = (n) => n.replace(/^Claude\s+/, "");

// Mix a hex colour toward black (f<0) or white (f>0), |f| in [0,1]. Used to give
// each model in a lab its own shade so same-colour curves are still separable.
function ccShade(hex, f) {
  const m = String(hex).replace("#", "");
  if (m.length < 6) return hex;
  const r = parseInt(m.slice(0, 2), 16), g = parseInt(m.slice(2, 4), 16), b = parseInt(m.slice(4, 6), 16);
  const t = f < 0 ? 0 : 255, a = Math.abs(f);
  const h = (c) => Math.round(c + (t - c) * a).toString(16).padStart(2, "0");
  return "#" + h(r) + h(g) + h(b);
}

// Decade tick label from its exponent: -4 -> "$0.0001", -1 -> "$0.10", 0 -> "$1".
function ccTickLabel(e) {
  if (e >= 0) return "$" + Math.round(Math.pow(10, e)).toLocaleString();
  if (e === -1) return "$0.10";
  if (e === -2) return "$0.01";
  return "$0." + "0".repeat(-e - 1) + "1";
}
// Actual cost, two significant figures: 0.00069 -> "$0.00069", 0.038 -> "$0.038".
function ccFmtCost(v) {
  if (v == null) return "—";
  if (v >= 1) return "$" + v.toFixed(2);
  return "$" + Number(v.toPrecision(2)).toString();
}

// Single-model Pareto set: points no single other model beats on both axes.
// These are the only sensible mix ingredients, and (unlike before) they are used
// internally to build the frontier — they are not drawn as hollow markers.
function ccSinglePareto(pts) {
  return pts.filter((p) => !pts.some((q) =>
    q !== p && q.x <= p.x && q.y >= p.y && (q.x < p.x || q.y > p.y)));
}

// Score of routing a fraction w of queries to A and (1-w) to B, for a score that
// blends LINEARLY under mixing (DTBench accuracy). Exact.
function ccMixLinear(a, b, w) { return w * a.y + (1 - w) * b.y; }

// Same, for LMCA — which is a Pearson r between the model's raw per-critique
// scores and the human target, so it does NOT blend linearly.
//
// Condition on the routing indicator. The covariance with the human target is a
// clean weighted average, but the model-side variance follows the law of total
// variance and picks up a between-model term:
//
//   Cov = w Cov_A + (1-w) Cov_B                    (linear; routing is independent
//                                                   of the item, so no cross term)
//   Var = w V_A + (1-w) V_B + w(1-w)(mu_A - mu_B)^2
//   r   = Cov / (sigma_x sigma_y)
//
// sigma_y (the human target's spread) is common to both models and cancels once
// each Cov is written as r*sigma_x*sigma_y, so the mix score needs only each
// model's own r, sigma and mu — exactly the lmca / lmca_variance / lmca_mean
// columns. Both correction terms only ADD to the denominator, so a mix scores at
// or below the straight line between its endpoints: splicing two different rating
// scales into one vector injects variance that is uncorrelated with the target.
function ccMixLmca(a, b, w) {
  if (a.sd == null || b.sd == null) return ccMixLinear(a, b, w);   // no stats -> linear
  const num = w * (a.y / 100) * a.sd + (1 - w) * (b.y / 100) * b.sd;
  const dmu = a.mu - b.mu;
  const varMix = w * a.sd * a.sd + (1 - w) * b.sd * b.sd + w * (1 - w) * dmu * dmu;
  if (!(varMix > 0)) return ccMixLinear(a, b, w);
  return 100 * (num / Math.sqrt(varMix));
}

// Path along a model's effort ladder where each segment is the ROUTING MIX between
// two adjacent tiers, not a line drawn between two dots.
//
// Same construction as the frontier: send a fraction w of queries to tier a and
// (1-w) to tier b. Cost blends linearly, the score blends per mixScore (linear for
// DTBench accuracy, ccMixLmca for LMCA). So every point on the segment is a
// configuration you could actually run, and the segment is the achievable set
// between the two tiers rather than interpolation.
//
// It renders as a curve for two independent reasons, both real rather than
// cosmetic: a linear-cost blend is not a straight line once x is log-scaled (it
// bows toward the cheaper tier), and for LMCA the correlation mix bows below the
// chord as well. Sampled in mix space and mapped through px/py, so the drawn
// curve follows the axis exactly the way the frontier does.
function ccMixPath(pts, mixScore, px, py, steps) {
  if (pts.length === 0) return "";
  if (pts.length === 1) return "";
  const N = steps || 24;
  let d = "";
  for (let i = 0; i < pts.length - 1; i++) {
    const a = pts[i], b = pts[i + 1];
    for (let t = 0; t <= N; t++) {
      const w = 1 - t / N;                       // weight on the cheaper tier a
      const x = w * a.x + (1 - w) * b.x;         // cost mixes linearly
      const y = mixScore(a, b, w);
      d += `${i === 0 && t === 0 ? "M" : "L"}${px(x).toFixed(1)},${py(y).toFixed(1)}`;
    }
  }
  return d;
}

// Mixing-aware frontier, as the upper envelope of every pairwise routing mix over
// the single-model Pareto set, sampled on a log-cost grid. Cost always mixes
// linearly, so a pair spans [x_a, x_b] with w = (x_b - x)/(x_b - x_a).
//
// This replaces the old upper convex hull. The hull IS the envelope when the
// score mixes linearly (DTBench), so nothing changes there; for LMCA the mix
// curves bow below their chords and the hull was an over-estimate.
function ccMixFrontier(pts, mixScore, steps) {
  const cand = ccSinglePareto(pts);
  if (cand.length === 0) return { samples: [], onFrontKeys: new Set() };

  // Best score achievable at EXACTLY this cost, over every single model and every
  // pairwise routing mix whose cost range spans it. Note mixScore(a, b, 1) === a.y
  // for both metrics, so a candidate is always a lower bound on the envelope at
  // its own cost — which is what makes the vertex test below exact.
  const envAt = (x) => {
    let best = -Infinity;
    for (const a of cand) {
      for (const b of cand) {
        if (a.x >= b.x) continue;
        // Tolerance: grid endpoints and candidate costs can land a hair outside a
        // span through floating point.
        if (x < a.x * (1 - 1e-9) || x > b.x * (1 + 1e-9)) continue;
        const w = Math.min(1, Math.max(0, (b.x - x) / (b.x - a.x)));
        const y = mixScore(a, b, w);
        if (y > best) best = y;
      }
    }
    // Degenerate case: a lone candidate, or a cost no pair spans.
    for (const p of cand) {
      if (Math.abs(p.x - x) <= Math.abs(x) * 1e-9 && p.y > best) best = p.y;
    }
    return best;
  };

  const xs = cand.map((p) => p.x);
  const lo = log10(Math.min(...xs));
  const top = cand.reduce((m, p) => (p.y > m.y ? p : m), cand[0]);
  // Never draw past the top-scoring model: beyond it you pay more for less.
  const hiCut = Math.min(log10(Math.max(...xs)), log10(top.x));
  const N = steps || 240;
  const samples = [];
  for (let i = 0; i <= N; i++) {
    const x = Math.pow(10, lo + ((hiCut - lo) * i) / (N || 1));
    const y = envAt(x);
    if (Number.isFinite(y)) samples.push({ x, y });
  }

  // Ringed markers: a model is a frontier vertex when nothing beats it at its own
  // cost — no other model, no mix. Evaluated at exactly p.x. The previous version
  // compared against the NEAREST GRID SAMPLE, which silently un-ringed real
  // vertices wherever the envelope climbs steeply between samples (the cheap end).
  const onFrontKeys = new Set();
  for (const p of cand) {
    if (p.y >= envAt(p.x) - 1e-6) onFrontKeys.add(p.name + "|" + p.effort);
  }
  return { samples, onFrontKeys };
}

function PerfCostChart({
  id, title, eyebrow, metricKey, costKey, orgColors, orgOrder,
  yMin, yMax, yTicks, yCeiling, yCeilingLabel, yLabel, yFmt, yTipFmt, xLabel, caption,
}) {
  const [isPhone, setIsPhone] = useCcState(
    () => typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia(CC_PHONE_MQ).matches : false);
  React.useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(CC_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 CC = isPhone ? CC_PHONE : CC_DESK;
  const DOT_R = isPhone ? 5.5 : 4;
  const AXT_X = isPhone ? 0 : 16;
  const [hover, setHover] = useCcState(null);
  // Which models' inference-time-scaling curves are drawn (per-model toggle).
  // Empty by default — the chart opens as the frontier scatter; you switch on
  // whichever curves you want, so they never all pile up at once.
  const [active, setActive] = useCcState(() => new Set());
  const [hidden, setHidden] = useCcState({});
  const ttRef = React.useRef(null);
  const [ttFit, setTtFit] = useCcState({ flip: false, nudge: 0 });

  const colorFor = (org) => (orgColors && orgColors[org]) || "#9a938a";
  const isHidden = (org) => !!hidden[org];

  // Build points from MODELS_ALL: any model with this metric score and a
  // positive per-response cost on this benchmark. Effort variants come through
  // as their own points (they carry effort + base for the scaling curves).
  const allPts = (window.MODELS_ALL || window.MODELS || [])
    .map((m) => ({
      name: m.name, org: m.org,
      x: m[costKey], y: m.scores[metricKey],
      effort: m.effort || "", base: m.base || "",
      // raw-score spread/mean, for the correlation-aware LMCA mixing
      sd: m.lmcaVar != null && m.lmcaVar > 0 ? Math.sqrt(m.lmcaVar) : null,
      mu: m.lmcaMean,
    }))
    .filter((p) => p.x != null && p.x > 0 && p.y != null);

  const pts = allPts.filter((p) => !isHidden(p.org));

  // Log-x domain from the visible points, padded by ~0.3 of a decade each side.
  const lxs = pts.map((p) => log10(p.x));
  const lmin = lxs.length ? Math.min(...lxs) : -4;
  const lmax = lxs.length ? Math.max(...lxs) : 0;
  const L0 = lmin - 0.3, L1 = lmax + 0.3;
  const xTickExps = [];
  for (let e = Math.ceil(L0); e <= Math.floor(L1); e++) xTickExps.push(e);

  const px = (x) => CC.ml + ((log10(x) - L0) / (L1 - L0)) * (CC.W - CC.ml - CC.mr);
  const py = (y) => CC.mt + (1 - (y - yMin) / (yMax - yMin)) * (CC.H - CC.mt - CC.mb);

  // Mixing-aware frontier (upper convex hull in linear cost space). Each hull
  // segment is a routing mix between its two endpoint models, so it's drawn
  // sampled in LINEAR space and mapped through the log-x scale, which bends the
  // straight linear-$ segment into the correct curve on the axis.
  // LMCA is a correlation, so mixes bow below their chords; DTBench accuracy
  // mixes linearly, where the envelope reduces to the old convex hull.
  const mixScore = metricKey === CC_LMCA ? ccMixLmca : ccMixLinear;
  const front = ccMixFrontier(pts, mixScore);
  const onFront = (p) => front.onFrontKeys.has(p.name + "|" + p.effort);
  const frontier = front.samples;
  const frontierPath = front.samples
    .map((s, i) => `${i ? "L" : "M"}${px(s.x).toFixed(1)},${py(s.y).toFixed(1)}`)
    .join(" ");
  // Per-model curve colours: each multi-effort model gets its lab's hue varied
  // in lightness, so two same-lab curves (e.g. all three Anthropic) stay apart.
  //
  // A model only earns a toggle if it has 2+ plottable points ON THIS CHART —
  // the same test the curve renderer applies below. Having effort rows in the
  // data is not enough: a tier with no per-response cost for this benchmark
  // can't be placed on the x-axis, so e.g. the Gemini ladders (priced at high
  // only) would otherwise offer a toggle that draws nothing when clicked.
  // Counted over allPts, not pts, so hiding an org via the legend doesn't make
  // toggles come and go.
  const baseCount = new Map();
  for (const p of allPts) if (p.base) baseCount.set(p.base, (baseCount.get(p.base) || 0) + 1);
  const baseInfo = new Map();   // base -> { org }
  for (const p of allPts) {
    if (!p.base || baseInfo.has(p.base) || baseCount.get(p.base) < 2) continue;
    baseInfo.set(p.base, { org: p.org });
  }
  const CURVE_ORG_ORDER = ["Anthropic", "OpenAI", "Google DeepMind"];
  const curveModels = Array.from(baseInfo.keys()).sort((a, b) => {
    const ra = CURVE_ORG_ORDER.indexOf(baseInfo.get(a).org);
    const rb = CURVE_ORG_ORDER.indexOf(baseInfo.get(b).org);
    if (ra !== rb) return (ra < 0 ? 99 : ra) - (rb < 0 ? 99 : rb);
    const ka = CC_CURVE_RANK.indexOf(a), kb = CC_CURVE_RANK.indexOf(b);
    return (ka < 0 ? 99 : ka) - (kb < 0 ? 99 : kb) || a.localeCompare(b);
  });
  const curveColor = {};
  const byOrgList = {};
  for (const b of curveModels) (byOrgList[baseInfo.get(b).org] ||= []).push(b);
  for (const org of Object.keys(byOrgList)) {
    const list = byOrgList[org], n = list.length;
    list.forEach((b, i) => {
      // i === 0 gets the lab colour unshaded; the rest lighten from it. The
      // spread is deliberately wide so same-lab curves separate — the cost is
      // contrast: against the cream page the palest OpenAI curve is ~1.4:1 and
      // the palest Google one ~1.2:1, both below the 3:1 WCAG non-text bar.
      // Google's yellow is the weak case, only 1.75:1 even unshaded.
      const f = n === 1 ? 0 : (0.62 * i) / (n - 1);
      curveColor[b] = ccShade(colorFor(org), f);
    });
  }

  // Active inference-time scaling curves, ordered low → max.
  const curvesOn = active.size > 0;
  const curves = [];
  {
    const byBase = new Map();
    for (const p of pts) {
      if (!p.base || !active.has(p.base)) continue;
      if (!byBase.has(p.base)) byBase.set(p.base, []);
      byBase.get(p.base).push(p);
    }
    for (const [base, group] of byBase) {
      const ordered = group.slice().sort(
        (a, b) => CC_EFFORT_ORDER.indexOf(a.effort) - CC_EFFORT_ORDER.indexOf(b.effort));
      if (ordered.length < 2) continue;
      curves.push({ base, color: curveColor[base], pts: ordered });
    }
  }
  // Endpoint labels for the active curves, de-collided vertically where their
  // endpoints sit near each other in x.
  const curveLabels = curves.map((c) => {
    const last = c.pts[c.pts.length - 1];
    const text = ccShort(c.base);
    const cx = px(last.x);
    let anchor = "start", lx = cx + 8;
    if (lx + text.length * 7 > CC.W - CC.mr) { anchor = "end"; lx = cx - 8; }
    return { color: c.color, text, cx, x: lx, anchor, y: py(last.y) - 6 };
  }).sort((a, b) => a.y - b.y);
  const LAB_GAP = 15;
  const placedLabels = [];
  for (const lp of curveLabels) {
    for (const q of placedLabels) {
      if (Math.abs(q.cx - lp.cx) < 95 && lp.y < q.y + LAB_GAP) lp.y = q.y + LAB_GAP;
    }
    placedLabels.push(lp);
  }

  // Legend order: major labs first, then orgOrder, then the rest.
  const MAJOR_LABS = ["OpenAI", "Anthropic", "Google DeepMind"];
  const orgRank = (o) => {
    const m = MAJOR_LABS.indexOf(o);
    if (m >= 0) return m;
    const i = (orgOrder || []).indexOf(o);
    return 100 + (i < 0 ? 900 : i);
  };
  const orgsPresent = Array.from(new Set(allPts.map((p) => p.org))).sort((a, b) => orgRank(a) - orgRank(b));
  const toggleOrg = (org) => setHidden((h) => ({ ...h, [org]: !h[org] }));

  const cy = CC.mt + (CC.H - CC.mt - CC.mb) / 2;
  const hoverP = hover != null && !isHidden(hover.org) ? hover : null;
  const hoverLeftPct = hoverP ? (px(hoverP.x) / CC.W) * 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;
    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]);

  const legend = (
    <div className="trend-legend">
      {orgsPresent.map((org) => (
        <button key={org} type="button"
          className={"tc-leg" + (isHidden(org) ? " is-off" : "")}
          onClick={() => toggleOrg(org)}
          title={isHidden(org) ? "Show " + org : "Hide " + org}>
          <span className="tc-leg-dot" style={{ "--leg-c": colorFor(org) }} />
          {org}
        </button>
      ))}
    </div>
  );

  return (
    <section className="trend-wrap" id={id}>
      <div className="trend-head">
        <div>
          {/* Optional: the two cost charts sit together, so only the first
              carries the eyebrow and it reads as one heading over both. */}
          {eyebrow ? <div className="eyebrow">{eyebrow}</div> : null}
          <div className="trend-title">{title}</div>
        </div>
      </div>

      <div style={{ display: "flex", gap: "16px 20px", flexWrap: "wrap", margin: "0 auto 20px" }}>
        <div className="filter-group">
          <div className="filter-label">Inference-time scaling curves</div>
          <div className="filter-opts">
            <button type="button" onClick={() => setActive(new Set(curveModels))}
              className={"filter-chip" + (curveModels.length && active.size === curveModels.length ? " is-active" : "")}>All</button>
            <button type="button" onClick={() => setActive(new Set())}
              className={"filter-chip" + (active.size === 0 ? " is-active" : "")}>None</button>
            {curveModels.map((b) => (
              <button key={b} type="button"
                onClick={() => setActive((s) => { const n = new Set(s); n.has(b) ? n.delete(b) : n.add(b); return n; })}
                className={"filter-chip" + (active.has(b) ? " is-active" : "")}>
                <span style={{ display: "inline-block", width: "9px", height: "9px", borderRadius: "50%",
                  background: curveColor[b], marginRight: "6px", verticalAlign: "middle" }} />
                {ccShort(b)}
              </button>
            ))}
          </div>
        </div>
      </div>

      <div className="trend-plot">
        <svg viewBox={`${CC.vbX} 0 ${CC.W - CC.vbX} ${CC.H}`} className="trend-svg" preserveAspectRatio="xMidYMid meet">
          <defs>
            <clipPath id={`cc-clip-${id}`}>
              <rect x={CC.ml} y={CC.mt} width={CC.W - CC.ml - CC.mr} height={CC.H - CC.mt - CC.mb} />
            </clipPath>
          </defs>

          {/* y gridlines + labels */}
          {yTicks.map((v) => (
            <g key={`y${v}`}>
              <line x1={CC.ml} x2={CC.W - CC.mr} y1={py(v)} y2={py(v)} className="tc-grid" />
              <text x={CC.ml - 12} y={py(v)} className="tc-ylabel" dominantBaseline="middle" textAnchor="end">{yFmt(v)}</text>
            </g>
          ))}
          {/* x decade ticks + labels */}
          {xTickExps.map((e) => {
            const xv = Math.pow(10, e);
            return (
              <g key={`x${e}`}>
                <line x1={px(xv)} x2={px(xv)} y1={CC.mt} y2={CC.H - CC.mb} className="tc-grid" />
                <line x1={px(xv)} x2={px(xv)} y1={CC.H - CC.mb} y2={CC.H - CC.mb + 6} className="tc-tick" />
                <text x={px(xv)} y={CC.H - CC.mb + 22} className="tc-xlabel" textAnchor="middle">{ccTickLabel(e)}</text>
              </g>
            );
          })}
          <line x1={CC.ml} x2={CC.W - CC.mr} y1={CC.H - CC.mb} y2={CC.H - CC.mb} className="tc-axis" />

          {/* axis titles */}
          <text className="tc-axis-title" transform={`rotate(-90 ${AXT_X} ${cy})`} x={AXT_X} y={cy}
                textAnchor="middle" style={isPhone ? { fontSize: "13px" } : undefined}>{yLabel}</text>
          <text className="tc-xlabel" x={CC.ml + (CC.W - CC.ml - CC.mr) / 2} y={CC.H - CC.mb + 44} textAnchor="middle">{xLabel}</text>

          {/* ceiling reference. The label is opt-in (yCeilingLabel) rather than
              derived from yCeiling: on DTBench the ceiling is a trivial 100 at
              the top gridline, where a caption would just be noise. Styling
              mirrors trend-chart.jsx's ceiling label so the two charts match. */}
          {yCeiling != null && (
            <line x1={CC.ml} x2={CC.W - CC.mr} y1={py(yCeiling)} y2={py(yCeiling)}
                  stroke="var(--ink-2, #3a3730)" strokeWidth="1.25" strokeDasharray="5 4" strokeOpacity="0.55" />
          )}
          {yCeiling != null && yCeilingLabel && (
            <text x={CC.ml + (CC.W - CC.ml - CC.mr) / 2} y={py(yCeiling) - 7}
                  className="tc-xlabel" textAnchor="middle"
                  style={{ fontStyle: "italic", opacity: 0.7 }}>{yCeilingLabel}</text>
          )}

          <g clipPath={`url(#cc-clip-${id})`}>
            {/* mixing-aware frontier line (linear-$ segments, curved by log-x) */}
            {frontier.length > 1 && (
              <path d={frontierPath} fill="none" stroke="var(--teal)" strokeWidth="2.25" strokeOpacity="0.9" />
            )}

            {/* Scatter points (dimmed while any effort curve is overlaid).
                SVG paints in document order, so this is drawn score-ascending:
                where dots overlap, the higher-scoring one lands on top rather
                than whichever happened to come first in the data. The hovered
                point sorts last of all, so it is never buried by a neighbour
                while its tooltip is open. */}
            {pts
              .slice()
              .sort((a, b) => (a === hoverP ? 1 : b === hoverP ? -1 : a.y - b.y))
              .map((p, i) => {
              const front = onFront(p);
              const dim = curvesOn ? 0.16 : (front ? 0.95 : 0.78);
              return (
                <circle
                  key={p.name + p.effort + i}
                  cx={px(p.x)} cy={py(p.y)}
                  r={hoverP === p ? DOT_R + 2 : (front && !curvesOn ? DOT_R + 0.5 : DOT_R)}
                  fill={colorFor(p.org)}
                  fillOpacity={dim}
                  stroke={!curvesOn && front ? "var(--teal)" : "none"}
                  strokeWidth={!curvesOn && front ? 1.5 : 0}
                  className="tc-pt"
                  onMouseEnter={() => setHover(p)}
                  onMouseLeave={() => setHover(null)}
                />
              );
            })}

            {/* inference-time scaling curves (paths + markers, clipped) */}
            {curves.map((c) => {
              const col = c.color;
              const d = ccMixPath(c.pts, mixScore, px, py);
              return (
                <g key={"curve" + c.base}>
                  <path d={d} fill="none" stroke={col} strokeWidth="2.25" strokeOpacity="0.95"
                        strokeLinejoin="round" strokeLinecap="round" />
                  {c.pts.map((p, i) => (
                    <circle key={c.base + p.effort + i} cx={px(p.x)} cy={py(p.y)}
                      r={hoverP === p ? DOT_R + 2 : DOT_R + 0.5} fill={col} fillOpacity="0.95"
                      className="tc-pt"
                      onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} />
                  ))}
                </g>
              );
            })}
          </g>

          {/* curve endpoint labels — outside the clip (may sit in the right
              margin), de-collided vertically, paper halo so they read over dots */}
          {placedLabels.map((lp, k) => (
            <text key={"lab" + k} x={lp.x} y={lp.y} className="tc-label" textAnchor={lp.anchor}
                  style={{ fill: lp.color, paintOrder: "stroke", stroke: "var(--paper, #f7f3ea)", strokeWidth: 3, strokeLinejoin: "round" }}>
              {lp.text}
            </text>
          ))}
        </svg>

        {hoverP && (() => {
          const p = hoverP;
          const effTag = p.effort ? " · " + p.effort : "";
          return (
            <div ref={ttRef}
                 className={"tc-tooltip" + (ttFit.flip ? " tc-tooltip--flip" : "")}
                 style={{ left: `${hoverLeftPct}%`, top: `${(py(p.y) / CC.H) * 100}%`, "--tt-nudge": `${ttFit.nudge}px` }}>
              <div className="tc-tt-name">{p.base && p.name !== p.base ? p.base : p.name}{effTag}</div>
              <div className="tc-tt-meta mono-small">{p.org} · {ccFmtCost(p.x)} / response</div>
              <div className="tc-tt-score" style={{ color: colorFor(p.org) }}>{yTipFmt(p.y)}</div>
            </div>
          );
        })()}
      </div>

      {legend}
      {caption ? <p className="trend-figcaption mono-small">{caption}</p> : null}
    </section>
  );
}

function LmcaCostChart({ orgColors }) {
  return (
    <PerfCostChart
      id="lmca-cost"
      title="LMCA performance vs. cost"
      eyebrow="Cost–performance frontier"
      metricKey={CC_LMCA} costKey="lmcaCost"
      orgColors={orgColors}
      orgOrder={window.ORGS || []}
      yMin={0} yMax={104} yCeiling={85} yCeilingLabel="Estimated ceiling (85)"
      yTicks={[0, 20, 40, 60, 80, 100]}
      yLabel="LMCA score" yFmt={(v) => v} yTipFmt={(v) => v.toFixed(1)}
      xLabel="Avg cost per response (USD, log scale)"
      caption="The teal line shows the cost-performance pareto frontier: the best performance achievable at a given average cost, taking into account the possibility of randomising between different models. We do not have results for every different effort level for every model, so best performance at a given cost might be achievable using a model at an effort level we have not benchmarked. The inference-time scaling curves show the returns to effort for given models."
    />
  );
}

function DtbenchCostChart({ orgColors }) {
  return (
    <PerfCostChart
      id="dtbench-cost"
      title="DTBench performance vs. cost"
      metricKey={CC_DT} costKey="dtCost"
      orgColors={orgColors}
      orgOrder={window.ORGS || []}
      yMin={0} yMax={104} yCeiling={100} yTicks={[0, 20, 40, 60, 80, 100]}
      yLabel="DTBench score" yFmt={(v) => v} yTipFmt={(v) => v.toFixed(1)}
      xLabel="Avg cost per response (USD, log scale)"
      caption="The teal line shows the cost-performance pareto frontier: the best performance achievable at a given average cost, taking into account the possibility of randomising between different models. We do not have results for every different effort level for every model, so best performance at a given cost might be achievable using a model at an effort level we have not benchmarked. The inference-time scaling curves show the returns to effort for given models."
    />
  );
}

window.PerfCostChart = PerfCostChart;
window.LmcaCostChart = LmcaCostChart;
window.DtbenchCostChart = DtbenchCostChart;
