// Leaderboard component — supports table, card, chart layouts
const { useState, useMemo, useRef, useEffect } = React;

// Companies to hide from the leaderboard (they still appear on their relevant
// dataset charts, which read a separate data path). Reka only has DTBench data.
const LB_EXCLUDE_ORGS = new Set(["Reka"]);
const lbOrgs = () => (window.ORGS || []).filter((o) => !LB_EXCLUDE_ORGS.has(o));

// Phone detection, same breakpoint and pattern as the charts.
const LB_PHONE_MQ = "(max-width: 760px)";
function useIsPhone() {
  const [is, setIs] = useState(() => typeof window !== "undefined" && window.matchMedia
    ? window.matchMedia(LB_PHONE_MQ).matches : false);
  useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(LB_PHONE_MQ);
    const on = () => setIs(mq.matches);
    on();
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => (mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on));
  }, []);
  return is;
}

// Label for the filtered view. It doubles as the FilterGroup's option value, so
// it is a constant rather than three copies of a string literal.
const VIEW_BEST = "Company's best + flagship";

// Models pinned into the "Company's best + flagship" view alongside each
// company's top scorer. Which model is a lab's flagship is an editorial call
// that the data cannot derive — Claude Fable 5, for one, is beaten by Opus 5 on
// CRI, on recency and on Arena elo, so no computed rule surfaces it. Hence a
// hand-kept list, deliberately here rather than scattered through the render.
// Names must match the `model` column of data/cri-scores.csv exactly.
// Edit this list as labs ship new flagships.
const FLAGSHIP_MODELS = new Set([
  "Claude Fable 5",
  "Muse Spark 1.2",
]);

function orgLogo(org) {
  const map = {
    "OpenAI": "assets/logos/openai.svg",
    "Anthropic": "assets/logos/anthropic.svg?v=2",
    "Google DeepMind": "assets/logos/google-deepmind.svg",
    "Mistral": "assets/logos/mistral.svg",
    "SpaceXAI": "assets/logos/spacexai.svg",
    "Meta": "assets/logos/meta.svg",
    "Alibaba": "assets/logos/alibaba.svg",
    "DeepSeek": "assets/logos/deepseek.svg",
    "Xiaomi": "assets/logos/xiaomi.svg",
    "Z.ai": "assets/logos/z-ai.svg",
    "Moonshot AI": "assets/logos/moonshot.svg",
    "MiniMax": "assets/logos/minimax.svg",
    "Reka": "assets/logos/reka.svg",
    "Cohere": "assets/logos/cohere.png",
    "NVIDIA": "assets/logos/nvidia.svg",
    "Thinking Machines": "assets/logos/thinking-machines.svg",
  };
  return map[org] || null;
}

function orgInitials(org) {
  return org.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}

// Dataset detail pages, keyed by the dataset names in window.DATASETS (data.js).
// "Overall" is the composite CRI, not a dataset, so it has no page and stays
// plain text in the expanded rows. Hrefs match the site nav (shared-layout.jsx).
const DATASET_HREFS = {
  "Argument evaluation (LMCA)": "lmca.html",
  "Consistency (ACCoRD)": "accord.html",
  "Decision theory (DTBench)": "dtbench.html",
};
const dsHref = (d) => DATASET_HREFS[d] || null;

// Display label for a dataset filter value ("Overall" stays the internal key).
function dsLabel(d) {
  return d === "Overall" ? "Overall (CRI)" : d;
}

// Long names get an explicit two-line split (at the space nearest the middle).
// With a real <br> the name's box shrinks to its longest line, so the org logo
// sits right next to the text — a soft-wrapped span would fill the whole
// column width and leave the logo stranded at the far edge.
function splitModelName(name, maxChars = 21) {
  if (name.length <= maxChars) return name;
  let best = null;
  const mid = name.length / 2;
  for (let i = name.indexOf(" "); i !== -1; i = name.indexOf(" ", i + 1)) {
    if (best === null || Math.abs(i - mid) < Math.abs(best - mid)) best = i;
  }
  if (best === null) return name;
  return (
    <React.Fragment>
      {name.slice(0, best)}
      <br />
      {name.slice(best + 1)}
    </React.Fragment>
  );
}

// 95% CI half-width (±) for a model on the currently-shown metric, read from the
// CSV (m.ci, populated in data.js). Returns null when no CI is available for that
// metric — the CI marker is then simply not drawn.
function ciFor(m, dsFilter) {
  if (!m.ci) return null;
  const v = dsFilter === "Overall" ? m.ci.overall : m.ci[dsFilter];
  return v == null ? null : v;
}

function CIMarker({ style, score, ci, color }) {
  const lo = Math.max(0, score - ci);
  const hi = Math.min(100, score + ci);
  const left = `${lo}%`;
  const width = `${hi - lo}%`;
  if (style === "band") {
    // diagonal hatch in a darker shade of the bar's own color;
    // for already-dark bars, lighten instead so the hatch stays visible
    const stripe = (() => {
      const c = (color || "").replace("#", "");
      if (c.length !== 6) return "rgba(0,0,0,0.42)";
      let r = parseInt(c.slice(0, 2), 16), g = parseInt(c.slice(2, 4), 16), b = parseInt(c.slice(4, 6), 16);
      const lum = 0.299 * r + 0.587 * g + 0.114 * b;
      if (lum < 70) {
        // pale: lift toward white
        r = Math.round(r + (255 - r) * 0.6);
        g = Math.round(g + (255 - g) * 0.6);
        b = Math.round(b + (255 - b) * 0.6);
      } else {
        // dark: scale toward black
        r = Math.round(r * 0.55);
        g = Math.round(g * 0.55);
        b = Math.round(b * 0.55);
      }
      return `rgb(${r},${g},${b})`;
    })();
    const hatch = `repeating-linear-gradient(45deg, ${stripe} 0, ${stripe} 3px, transparent 3px, transparent 6px)`;
    return <span className="ci-band" style={{ left, width, backgroundImage: hatch }} />;
  }
  if (style === "caps") {
    return (
      <span className="ci-caps" style={{ left, width }}>
        <span className="ci-cap ci-cap--l" />
        <span className="ci-cap ci-cap--r" />
      </span>
    );
  }
  // whiskers (default)
  return (
    <span className="ci-whisker" style={{ left, width }}>
      <span className="ci-cap ci-cap--l" />
      <span className="ci-cap ci-cap--r" />
    </span>
  );
}

function Leaderboard({ layout, teal, barColor, orgColors, ciStyle }) {
  const isPhone = useIsPhone();
  const [selectedOrgs, setSelectedOrgs] = useState(() => new Set(lbOrgs()));
  const [bestOnly, setBestOnly] = useState(true);
  const [dsFilter, setDsFilter] = useState("Overall");
  // Shows the CI markers on every visible row; off, they appear only on the
  // hovered row, which is desktop-only. One piece of state, two controls that
  // never coexist: the table-header button on desktop, the filter chips below
  // on phone. It lives up here because the phone control is in the filter
  // panel, which TableView does not own.
  const [showCI, setShowCI] = useState(false);

  const rows = useMemo(() => {
    let r = window.MODELS.slice();
    const getScore = (m) => dsFilter === "Overall" ? m.overall : m.scores[dsFilter];
    const sv = (x) => (x == null ? -1 : x); // sort missing scores last
    r = r.map((m) => ({ ...m, _score: getScore(m) }));

    r = r.filter((m) => selectedOrgs.has(m.org) && !LB_EXCLUDE_ORGS.has(m.org));

    // Every view lists only models that have a score for the selected benchmark
    // (Overall requires a complete CRI). A model with no data for this benchmark
    // — e.g. Reka has DTBench only — is dropped from that benchmark's view
    // rather than shown as "—". Purely data-driven: it reappears the moment its
    // score lands in the CSV.
    r = r.filter((m) => m._score != null);

    if (bestOnly) {
      const bestByOrg = new Map();
      for (const m of r) {
        const cur = bestByOrg.get(m.org);
        if (!cur || sv(m._score) > sv(cur._score)) bestByOrg.set(m.org, m);
      }
      const keep = new Map(Array.from(bestByOrg.values()).map((m) => [m.id, m]));
      // ...plus the pinned flagships, which by definition are not their company's
      // top scorer (if one is, it is already in the map and this is a no-op).
      // They still have to clear the filters above: a flagship stays hidden if its
      // company is deselected or it has no score for the selected benchmark.
      for (const m of r) if (FLAGSHIP_MODELS.has(m.name)) keep.set(m.id, m);
      r = Array.from(keep.values());
    }
    r.sort((a, b) => sv(b._score) - sv(a._score));
    return r;
  }, [selectedOrgs, bestOnly, dsFilter]);

  const maxScore = Math.max(...rows.map((r) => r._score ?? 0), 1);

  // Denominator for the "N/M models" count: every model that has a score for the
  // selected benchmark, before the company and best-by-company filters. The
  // default view therefore reads "15/131 models" (131 = models with a complete
  // CRI). Purely data-driven: both numbers come from data/cri-scores.csv.
  const totalForView = useMemo(() => {
    const orgOk = new Set(lbOrgs());
    const getScore = (m) => (dsFilter === "Overall" ? m.overall : m.scores[dsFilter]);
    return window.MODELS.filter((m) => orgOk.has(m.org) && getScore(m) != null).length;
  }, [dsFilter]);
  const countLabel = totalForView > rows.length
    ? `${rows.length}/${totalForView} models`
    : `${rows.length} ${rows.length === 1 ? "model" : "models"}`;

  const datasetsWithData = window.DATASETS.filter((d) => window.MODELS.some((m) => m.scores[d] != null));
  const datapoints = dsFilter === "Overall"
    ? datasetsWithData.reduce((s, d) => s + (window.DATASET_SIZES[d] || 0), 0)
    : (datasetsWithData.includes(dsFilter) ? (window.DATASET_SIZES[dsFilter] || 0) : 0);

  return (
    <section className="lb-wrap" id="leaderboard">
      <div className="lb-head">
        <div>
          <div className="eyebrow">Leaderboard</div>
        </div>
      </div>

      <div className="lb-filters">
        <FilterGroup
          label="View"
          value={bestOnly ? VIEW_BEST : "All"}
          options={[VIEW_BEST, "All"]}
          onChange={(v) => setBestOnly(v === VIEW_BEST)} />

        {/* Phone only. Desktop keeps the toggle in the table header, where it
            has Expand all / Collapse all to read as a control against; the
            header is display:none below 760px and touch has no hover, so on
            phone the control moves here and borrows the filter chips instead.
            Never both: one state, one visible control at any width. */}
        {isPhone && ciStyle && ciStyle !== "off" && (
          <FilterGroup
            label="Confidence intervals"
            value={showCI ? "On" : "Off"}
            options={["Off", "On"]}
            onChange={(v) => setShowCI(v === "On")} />
        )}

        <CompanyFilter
          selected={selectedOrgs}
          onChange={setSelectedOrgs} />

        <DatasetFilter
          value={dsFilter}
          options={["Overall", ...window.DATASETS]}
          onChange={setDsFilter} />

        {!isPhone && (
          <div className="lb-filter-meta">
            {countLabel} · {datapoints.toLocaleString()} data points
          </div>
        )}
      </div>

      {layout === "table" && <TableView rows={rows} dsFilter={dsFilter} maxScore={maxScore} teal={teal} barColor={barColor} orgColors={orgColors} ciStyle={ciStyle} bestOnly={bestOnly} showCI={showCI} setShowCI={setShowCI} onShowAllModels={() => setBestOnly(false)} />}
      {layout === "cards" && <CardView rows={rows} dsFilter={dsFilter} maxScore={maxScore} teal={teal} />}
      {layout === "chart" && <ChartView rows={rows} dsFilter={dsFilter} maxScore={maxScore} teal={teal} />}

      {/* Phone only, and the same line as the one in the filter panel above —
          rendered in one place or the other, never both. The panel is a wrapping
          flex row on phone, where this line took a whole row of its own at the
          end of the controls; below the table it reads as a footnote to the
          thing it counts. Desktop has the width to keep it in the panel. */}
      {isPhone && (
        <div className="lb-filter-meta lb-filter-meta--foot">
          {countLabel} · {datapoints.toLocaleString()} data points
        </div>
      )}
    </section>);

}

function CompanyFilter({ selected, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onDown = (e) => {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);

  const toggleOrg = (org) => {
    const next = new Set(selected);
    if (next.has(org)) next.delete(org); else next.add(org);
    onChange(next);
  };

  const allOn = selected.size === lbOrgs().length;
  const noneOn = selected.size === 0;
  const summary = allOn
    ? "All companies"
    : noneOn
    ? "None selected"
    : selected.size === 1
    ? Array.from(selected)[0]
    : `${selected.size} companies`;

  return (
    <div className="filter-group">
      <div className="filter-label">Company</div>
      <div className="company-dd" ref={ref}>
        <button
          type="button"
          className={"dd-trigger" + (open ? " is-open" : "")}
          onClick={() => setOpen((v) => !v)}>
          <span>{summary}</span>
          <span className="dd-caret" aria-hidden="true">▾</span>
        </button>
        {open && (
          <div className="dd-menu">
            {lbOrgs().map((org) => (
              <button
                key={org}
                type="button"
                className="dd-item"
                onClick={() => toggleOrg(org)}>
                <span className={"dd-check" + (selected.has(org) ? " is-on" : "")} aria-hidden="true">
                  {selected.has(org) ? "✓" : ""}
                </span>
                {orgLogo(org) ? (
                  <img className="dd-logo" data-org={org} src={orgLogo(org)} alt="" aria-hidden="true" />
                ) : (
                  <span className="org-mark" aria-hidden="true">{orgInitials(org)}</span>
                )}
                <span>{org}</span>
              </button>
            ))}
            <div className="dd-sep" />
            <div className="dd-actions">
              <button type="button" className="dd-link" onClick={() => onChange(new Set(lbOrgs()))}>Select all</button>
              <button type="button" className="dd-link" onClick={() => onChange(new Set())}>Clear</button>
            </div>
          </div>
        )}
      </div>
    </div>);

}

function FilterGroup({ label, value, options, onChange }) {
  return (
    <div className="filter-group">
      <div className="filter-label">{label}</div>
      <div className="filter-opts">
        {options.map((opt) =>
        <button
          key={opt}
          className={"filter-chip" + (value === opt ? " is-active" : "")}
          onClick={() => onChange(opt)}>

            {opt}
          </button>
        )}
      </div>
    </div>);

}

function DatasetFilter({ value, options, onChange }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
  }, [open]);
  return (
    <div className="filter-group">
      <div className="filter-label">Dataset</div>
      <div className="company-dd" ref={ref}>
        <button
          type="button"
          className={"dd-trigger" + (open ? " is-open" : "")}
          onClick={() => setOpen((v) => !v)}>
          <span>{dsLabel(value)}</span>
          <span className="dd-caret" aria-hidden="true">▾</span>
        </button>
        {open && (
          <div className="dd-menu">
            {options.map((opt) => (
              <button
                key={opt}
                type="button"
                className={"dd-item" + (value === opt ? " is-active" : "")}
                onClick={() => { onChange(opt); setOpen(false); }}>
                <span>{dsLabel(opt)}</span>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>);

}

function TableView({ rows, dsFilter, maxScore, teal, barColor, orgColors, ciStyle, bestOnly, showCI, setShowCI, onShowAllModels }) {
  const isPhone = useIsPhone();
  const [expanded, setExpanded] = useState(false);
  // Fold state = a baseline mode plus per-row exceptions, so "Expand all"
  // also applies to rows revealed later (e.g. via "Show all").
  const [foldMode, setFoldMode] = useState("collapsed");
  const [foldExceptions, setFoldExceptions] = useState(() => new Set());
  const isOpen = (id) => (foldMode === "expanded") !== foldExceptions.has(id);
  const toggleOpen = (id) =>
    setFoldExceptions((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  const setAllFolds = (mode) => { setFoldMode(mode); setFoldExceptions(new Set()); };
  // Desktop-only: hovering a row (or sub-row) reveals just that CI marker.
  // Touch has no mouseleave, so a tap would set these and leave the CI stuck on
  // — hence every setter below is gated on !isPhone. On phone the filter-panel
  // "Confidence intervals" toggle is the only way to show them.
  const [hoverId, setHoverId] = useState(null);
  const [hoverSub, setHoverSub] = useState(null);
  const colorFor = (m) =>
    barColor !== "teal" && orgColors && orgColors[m.org] ? orgColors[m.org] : teal;
  const subScores = (m) =>
    ["Overall", ...window.DATASETS]
      .filter((d) => d !== dsFilter)
      .map((d) => ({
        label: d,
        value: d === "Overall" ? m.overall : m.scores[d],
        ci: ciFor(m, d),
      }));
  const initial = 10;
  const visible = bestOnly || expanded ? rows : rows.slice(0, initial);
  const hidden = bestOnly ? 0 : Math.max(0, rows.length - initial);
  // Ranks reshuffle when the view/row-count changes, so jump back to the top
  // of the table (scroll-margin-top on .lb-wrap keeps the sticky header clear).
  const scrollToTableTop = () => {
    const el = document.getElementById("leaderboard");
    if (el) el.scrollIntoView({ behavior: "smooth" });
  };
  // Back-to-top pill: on phone, visible whenever the top of the leaderboard has
  // scrolled off, independent of the View or expanded state. IntersectionObserver
  // only fires on threshold crossings and this section is far taller than the
  // viewport, so read the offset directly on scroll, throttled to a frame.
  const [topOffScreen, setTopOffScreen] = useState(false);
  useEffect(() => {
    if (!isPhone) { setTopOffScreen(false); return; }
    const el = document.getElementById("leaderboard");
    if (!el) return;
    // One getBoundingClientRect per scroll event, and React bails out when the
    // boolean is unchanged, so this needs no rAF throttle — which also keeps it
    // working anywhere rAF is starved.
    const check = () => setTopOffScreen(el.getBoundingClientRect().top < 0);
    check();
    window.addEventListener("scroll", check, { passive: true });
    window.addEventListener("resize", check);
    return () => {
      window.removeEventListener("scroll", check);
      window.removeEventListener("resize", check);
    };
  }, [isPhone]);
  const openCount = visible.filter((m) => isOpen(m.id)).length;
  return (
    <div className="lb-table-wrap">
      <table className="lb-table">
        <thead>
          <tr>
            <th className="col-rank">#</th>
            <th className="col-model">Model</th>
            <th className="col-score">
              <div className="col-score-head">
                <span>{dsLabel(dsFilter)}</span>
                <span className="lb-fold-actions">
                  {openCount < visible.length && (
                    <button
                      type="button"
                      className="lb-collapse-all"
                      onClick={() => setAllFolds("expanded")}>
                      Expand all
                    </button>
                  )}
                  {openCount < visible.length && openCount > 0 && <span className="lb-fold-sep">·</span>}
                  {openCount > 0 && (
                    <button
                      type="button"
                      className="lb-collapse-all"
                      onClick={() => setAllFolds("collapsed")}>
                      Collapse all
                    </button>
                  )}
                  {/* Desktop home for the confidence-interval toggle. Bare text
                      reads as a control here because it sits after Collapse all
                      and a separator dot. Below 760px this whole thead is
                      display:none, so the phone copy is a FilterGroup in the
                      filter panel instead — hence the !isPhone gate, which keeps
                      the two out of the DOM at the same time. */}
                  {!isPhone && ciStyle && ciStyle !== "off" && visible.length > 0 && (
                    <React.Fragment>
                      <span className="lb-fold-sep">·</span>
                      <button
                        type="button"
                        className="lb-collapse-all"
                        aria-pressed={showCI}
                        onClick={() => setShowCI((v) => !v)}>
                        {showCI ? "Hide confidence intervals" : "Show confidence intervals"}
                      </button>
                    </React.Fragment>
                  )}
                </span>
              </div>
            </th>
          </tr>
        </thead>
        <tbody>
          {visible.map((m, i) =>
          <React.Fragment key={m.id}>
            <tr
              className={"lb-row lb-row--clickable" + (isOpen(m.id) ? " is-open" : "")}
              tabIndex={0}
              role="button"
              aria-expanded={isOpen(m.id)}
              aria-label={`${m.name} — show score breakdown`}
              onClick={() => toggleOpen(m.id)}
              onKeyDown={(e) => {
                if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
                  e.preventDefault();
                  toggleOpen(m.id);
                }
              }}
              /* Focus, not hover, is what a tap leaves behind: the row is a
                 tabIndex button, so tapping it focuses it and the focus outlives
                 the tap. Letting that set hoverId on phone would light a CI the
                 user then has no way to dismiss. Gated to desktop, where focus
                 is the keyboard equivalent of hover. */
              onFocus={() => { if (!isPhone) setHoverId(m.id); }}
              onBlur={() => { if (!isPhone) setHoverId(null); }}
              onMouseEnter={() => { if (!isPhone) setHoverId(m.id); }}
              onMouseLeave={() => { if (!isPhone) setHoverId(null); }}>
              <td className="col-rank mono">{String(i + 1).padStart(2, "0")}</td>
              <td className="col-model">
                <div className="model-name-row">
                  <span className="lb-caret" aria-hidden="true">{isOpen(m.id) ? "▾" : "▸"}</span>
                  <span className="model-name">
                    {splitModelName(m.name)}
                    {window.criStarForView(m, dsFilter) && (
                      <span
                        className="lb-partial-star"
                        title={m.coverageNote ? "Unfillable partial data — " + m.coverageNote : "Unfillable partial data"}>
                        *
                      </span>
                    )}
                  </span>
                  {orgLogo(m.org) ? (
                    <img className="org-logo" data-org={m.org} src={orgLogo(m.org)} alt={m.org} title={m.org} />
                  ) : (
                    <span className="org-mark" data-org={m.org} title={m.org} aria-hidden="true">{orgInitials(m.org)}</span>
                  )}
                </div>
              </td>
              <td className="col-score">
                <div className="score-cell">
                  <span className="score-num mono">{m._score == null ? "—" : m._score.toFixed(1)}</span>
                  <div className="score-bar">
                    <div
                    className="score-bar-fill"
                    style={{ width: `${m._score ?? 0}%`, background: colorFor(m) }} />
                    {ciStyle && ciStyle !== "off" && (showCI || hoverId === m.id) && m._score != null && ciFor(m, dsFilter) != null && (
                      <CIMarker style={ciStyle} score={m._score} ci={ciFor(m, dsFilter)} color={colorFor(m)} />
                    )}
                  </div>
                </div>
              </td>
            </tr>
            {isOpen(m.id) && subScores(m).map((s, si, arr) => (
              <tr
                key={m.id + s.label}
                className={"lb-subrow" + (si === arr.length - 1 ? " is-last" : "")}
                onMouseEnter={() => { if (!isPhone) setHoverSub(m.id + s.label); }}
                onMouseLeave={() => { if (!isPhone) setHoverSub(null); }}>
                <td className="col-rank"></td>
                <td className="col-model">
                  {dsHref(s.label) ? (
                    <a className="lb-sub-label text-link" href={dsHref(s.label)}>{s.label}</a>
                  ) : (
                    <span className="lb-sub-label">{s.label}</span>
                  )}
                </td>
                <td className="col-score">
                  <div className="score-cell">
                    <span className="score-num mono">{s.value == null ? "—" : s.value.toFixed(1)}</span>
                    <div className="score-bar">
                      <div
                        className="score-bar-fill"
                        style={{ width: `${s.value ?? 0}%`, background: colorFor(m) }} />
                      {ciStyle && ciStyle !== "off" && (showCI || hoverSub === (m.id + s.label)) && s.value != null && s.ci != null && (
                        <CIMarker style={ciStyle} score={s.value} ci={s.ci} color={colorFor(m)} />
                      )}
                    </div>
                  </div>
                </td>
              </tr>
            ))}
          </React.Fragment>
          )}
        </tbody>
      </table>
      {isPhone && topOffScreen && (
        <button type="button" className="lb-backtotop" onClick={scrollToTableTop}>
          Back to top<span className="lb-expand-caret" aria-hidden="true">↑</span>
        </button>
      )}
      {bestOnly ? (
        <button
          type="button"
          className="lb-expand"
          onClick={() => { setExpanded(true); onShowAllModels(); scrollToTableTop(); }}>
          Show all models
        </button>
      ) : hidden > 0 && (
        <button
          type="button"
          className={"lb-expand" + (expanded && !isPhone ? " lb-expand--floating" : "")}
          onClick={() => {
            if (expanded) { setExpanded(false); scrollToTableTop(); }
            else setExpanded(true);
          }}>
          {expanded ? "Show less" : "Show all"}
          <span className="lb-expand-caret" aria-hidden="true">{expanded ? "↑" : "↓"}</span>
        </button>
      )}
      {visible.some((m) => window.criStarForView(m, dsFilter)) && (
        <p className="lb-partial-note mono-small">* denotes unfillable partial data.</p>
      )}
    </div>);

}

function CardView({ rows, dsFilter, maxScore, teal }) {
  return (
    <div className="lb-cards">
      {rows.map((m, i) =>
      <article key={m.id} className="lb-card">
          <div className="lb-card-top">
            <span className="mono-small dim">#{String(i + 1).padStart(2, "0")}</span>
            <span className="mono-small dim">{m.org}</span>
          </div>
          <h3 className="lb-card-name">{m.name}</h3>
          <div className="lb-card-score">
            <div className="score-label mono-small">{dsFilter === "Overall" ? "Overall" : dsFilter}</div>
            <div className="score-big" style={{ color: teal }}>{m._score.toFixed(1)}</div>
          </div>
          <div className="lb-card-breakdown">
            {window.DATASETS.map((d) =>
          <div key={d} className="bd-row">
                <span className="bd-label">{d}</span>
                <span className="bd-val mono">{m.scores[d] == null ? "—" : m.scores[d].toFixed(1)}</span>
                <div className="bd-bar">
                  <div className="bd-bar-fill" style={{ width: `${m.scores[d] || 0}%`, background: teal }} />
                </div>
              </div>
          )}
          </div>
          <div className="lb-card-foot mono-small dim">{m.released}</div>
        </article>
      )}
    </div>);

}

function ChartView({ rows, dsFilter, maxScore, teal }) {
  // horizontal bar chart; if dataset === Overall, show one bar per model;
  // otherwise show grouped bars across datasets
  const showBreakdown = dsFilter === "Overall";
  const chartMax = 100;

  return (
    <div className="lb-chart">
      <div className="chart-axis">
        {[0, 25, 50, 75, 100].map((v) =>
        <div key={v} className="axis-tick" style={{ left: `${v}%` }}>
            <div className="axis-line" />
            <div className="axis-label mono-small">{v}</div>
          </div>
        )}
      </div>
      {rows.map((m, i) =>
      <div key={m.id} className="chart-row">
          <div className="chart-row-head">
            <span className="mono-small dim">#{String(i + 1).padStart(2, "0")}</span>
            <span className="chart-model-name">{m.name}</span>
            <span className="mono-small dim">{m.org}</span>
          </div>
          {showBreakdown ?
        <div className="chart-bars-group">
              {window.DATASETS.map((d, j) => {
            const v = m.scores[d];
            return (
              <div key={d} className="chart-bar-row">
                    <div className="chart-bar-label mono-small">{d}</div>
                    <div className="chart-bar-track">
                      <div
                    className="chart-bar-fill"
                    style={{
                      width: `${(v || 0) / chartMax * 100}%`,
                      background: teal,
                      opacity: 0.45 + 0.25 * (window.DATASETS.length - j)
                    }} />

                      <span className="chart-bar-val mono" style={{ left: `calc(${(v || 0) / chartMax * 100}% + 8px)` }}>
                        {v == null ? "—" : v.toFixed(1)}
                      </span>
                    </div>
                  </div>);

          })}
            </div> :

        <div className="chart-bar-row chart-bar-row--single">
              <div className="chart-bar-track">
                <div
              className="chart-bar-fill"
              style={{ width: `${m._score / chartMax * 100}%`, background: teal }} />

                <span className="chart-bar-val mono" style={{ left: `calc(${m._score / chartMax * 100}% + 8px)` }}>
                  {m._score.toFixed(1)}
                </span>
              </div>
            </div>
        }
        </div>
      )}
    </div>);

}

window.Leaderboard = Leaderboard;