Charts

Elo Ratings

An arena leaderboard over time — one line per model with a confidence band derived from its battle count, releases annotated with the move they produced, and a pairwise win-rate matrix that doubles as the card's control surface.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartEloRatingsData,
  ChartEloRatingsEvent,
  ChartEloRatingsModel,
  ChartEloRatingsPair,
} from "./chart-elo-ratings.contract"

/** What the grid is reading right now: one model against the field, or one pair. */
export type EloSelection = { kind: "model"; id: string } | { kind: "pair"; a: string; b: string }

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-elo-ratings.json

Prompt

Build a React + TypeScript + Tailwind "ChartEloRatings" card in hand-rolled SVG
plus one HTML grid (no charting library), with zod for the contract.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string;
    models: { id, label, points: { date: "YYYY-MM-DD", rating, battles >= 0 }[] }[];
    pairwise?: { a, b, winsA, winsB, ties }[];
    events?:   { date: "YYYY-MM-DD", label }[] }
- Props = z.infer of that schema plus height, bandZ (default 1.96), locale,
  defaultSelectedId, onSelectionChange, onRetry, emptyState, className. No
  hand-written parallel interface.
- `battles` is the count the rating was fitted on, including battles against
  models that are not on this card. `pairwise` is unordered — {a,b} and {b,a}
  are one record — and each pair is sent once. Refine: ready needs one model
  with one snapshot; model ids unique; a pair may not name itself, an unknown
  id, or repeat another pair.

Derived maths — computed in the component, never accepted from the payload
- Standard error of a rating: a Bradley-Terry fit takes n/4 units of
  information from n comparisons at even odds, so
  SE = 2 * (400 / ln 10) / sqrt(n) ~= 347.4 / sqrt(n). The band is bandZ * SE.
  Zero battles yields NO band: an unrated model has no error bar, which is not
  the same as a tight one.
- Standings: last snapshot per model, sorted by rating descending with payload
  order breaking exact ties; drift = current minus first rating.
- Statistical tie: two neighbours are separated only when
  gap > bandZ * sqrt(SEi^2 + SEj^2). Print "N of M neighbouring gaps clear both
  bands" and name the pairs that do not.
- Event delta: for each event inside the plotted window, difference the first
  snapshot at-or-after it against the last one strictly before it, per model;
  the biggest absolute mover is printed beside the event.
- Matrix cell: winsRow / (winsRow + winsCol) — the arena convention, ties
  excluded. The diagonal is that model against every recorded opponent.
- Expected score from the ladder: 1 / (1 + 10^((ratingCol - ratingRow) / 400)),
  weighted by decided battles on the diagonal. Print observed against implied
  plus the residual in points, and call out the pair where they disagree most.
- Win / tie / loss shares are apportioned by LARGEST REMAINDER over the full
  battle count, so every selection prints three numbers summing to exactly 100.

Behavior
- Four first-class branches in one bg-card panel: loading (a pulsing plot
  silhouette plus standings bars), empty (why a ratings card needs dated
  rating+battles pairs), error (message plus a "Try again" button only when
  onRetry exists), ready. A ready payload whose models carry no snapshots
  renders the empty branch rather than an axis with nothing on it.
- The plot: one polyline per model on a UTC time axis, a filled band with
  dashed edges for the models currently being read, a dot on each model's
  current snapshot, dashed rules at events with numbered badges nudged apart
  when two would collide, month ticks, and the date range as the axis caption.
  The y step is the smallest 1 / 2 / 2.5 / 5 rung that fits the domain, and a
  tick prints exactly the decimals its own step needs: a 2.5 rung labelled in
  whole points names a rating half a point off the line it sits on, and on a
  tight domain it labels two lines the same.
- The grid is the control surface, not a second table: role="grid" over a
  <table>, column 0 the standings row (swatch, rank, label, rating +- band,
  battles) and columns 1..N the matrix. Roving tabindex — exactly one cell is
  tabbable; arrows / Home / End move focus AND the reading, and a click does
  the same. Selecting a cell lights those two lines in the plot and dims the
  rest; a row header or a diagonal reads that model against the field.
- Defensive ingest: snapshots with an unparseable day, an unreadable rating or
  a repeat of a day already taken are dropped and counted; so are duplicate
  model ids, pairs naming a model that is not on the board, and events outside
  the plotted window. Every count is reported under the chart, never silently.

Rendering & styling
- Semantic tokens only. Series ink is var(--chart-1..5) by payload position,
  with a dash pattern stepping once the tokens repeat; bg-card, border,
  text-muted-foreground, bg-muted and ring-ring for focus and selection.
- Matrix tiles are color-mix(in oklab, <token or var(--muted-foreground)>
  <strength>%, var(--card)) — mixed toward the card so the tile stays legible
  in both themes, capped near 38% so the printed percentage keeps its
  contrast. The row's own token when the row is ahead, a neutral when behind.
- The band is a fill at ~0.26 opacity PLUS dashed edge strokes: a low-alpha
  wash alone is the first thing to vanish on a near-black card.
- Plot width comes from a ResizeObserver, disconnected on unmount and on node
  change. Line dimming transitions with motion-reduce:transition-none; the
  skeleton pulses with motion-reduce:animate-none.
- Accessibility: the plot is role="img" with a spoken summary plus a sr-only
  data table of the snapshots; grid cells carry an aria-label naming the
  matchup and its rate, and aria-selected marks the reading. Dates are parsed
  and formatted in UTC, so a snapshot never slides onto the previous day.
- cn() merges className; the root spreads the remaining props.

Customization levers
- Interval width: bandZ — 1.96 for 95%, 1 for +-1 SE, 2.58 for 99%. It drives
  the band, the tie test and every printed "+-N" from one number.
- Density: height (150-420) for the plot; drop the events strip by omitting
  `events`, drop the whole matrix by omitting `pairwise` (the grid degrades to
  a single navigable standings column).
- Palette: SERIES_INK is the one place ink is chosen — map it to a fixed token
  per vendor instead of cycling, and re-point tileTint's neutral if "behind"
  should read as destructive rather than grey.
- Matrix encoding: swap the cell from decisive win rate to the residual
  against the ladder (observed minus implied) when the audience is calibrating
  the rating fit rather than reading matchups; the tint formula takes any 0..1.
- Column width: the standings column is 46% of the table — widen it for long
  model ids, or drop its second line and lean on the readout panel instead.
- Copy: the caption sentences are assembled from derived values, so translate
  the strings without touching the maths.

Concepts

  • Band from the sample, not from the payload — the interval is computed from battles alone (SE = 2 · 400/ln10 / √n), so a model with 1,900 battles gets a visibly fat funnel that narrows as it accumulates. Nobody can send a flattering interval, and a rating with zero battles gets no band at all rather than a confident-looking hairline.
  • Statistical tie as a first-class verdict — neighbouring rows count as separated only when their gap clears z · √(SEa² + SEb²). The card counts how many gaps on the ladder survive that test and names the pairs that do not, which is the difference between a leaderboard and a ranking.
  • Matrix as control surface — the pairwise grid is not a second read-only table: it owns a roving tabindex, and moving through it re-lights the plot above. One role="grid" replaces a legend, a filter row and a set of toggles, and the diagonal is a door to "this model against the whole field".
  • Observed against implied — every cell is compared with the Elo expectation 1 / (1 + 10^(Δ/400)) for the same pair, so a matchup that disagrees with the ladder surfaces as a residual in points instead of being smoothed away by the fit that produced the ratings.
  • Largest-remainder shares — win / tie / loss are apportioned so the three printed percentages sum to exactly 100 in every selection, and those same three numbers set the widths of the stacked bar above them: a segment is never sized by one figure and labelled with another.
  • Events answer "why did it move?" — a release is a dated label, and the delta beside it is differenced out of the plotted snapshots rather than typed in, so an annotation can never claim a jump the line does not show.

On This Page