Win Probability
A four-state in-game win probability chart: one line around a 50% midline, the band shaded toward whichever side is favoured in that side's chart token, and key events pinned to the path — named on hover, focus and in the readout.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-win-probability.jsonPrompt
Build a React + TypeScript + Tailwind "Win Probability" chart component (zod, a cn() class merger;
hand-rolled SVG, no chart library). It draws one probability line over game time around a 50% midline,
shades the band between line and midline in the favoured side's colour, and pins key events to the path.
Contract
- zod schema, one source of truth (props are z.infer of it plus the render options below):
status: "loading" | "empty" | "error" | "ready"
title: string, caption?: string
home: string, away: string // the two sides, named once; away's probability is always 1 − probHome
samples: { t: number; probHome: number (0–1) }[]
events?: { t: number; label: string }[]
t is NUMERIC (minute, second, play number), never a category: the 50% crossings and the events both
land BETWEEN samples, and an interpolated t has nowhere to sit on a band scale.
- Render options on top of the contract: height (px, clamped 140–480), maxTicks (3–10),
formatTime (how every clock reading is printed), onRetry, className, ...div props. forwardRef to the card.
- All the maths live in exported pure functions beside the component, so a test can print the same
numbers the picture is made of: prepareWinProbability, probAt, buildWinProbabilityModel,
probabilityPath, runBandPath, buildWinProbabilityStops, stopBands, pickTickIndices.
Behavior
- Favourite runs are the whole component. Per segment take d0 = p0 − 0.5 and d1 = p1 − 0.5; when their
signs are strictly opposite the favourite turns over inside the segment at t = d0 / (d0 − d1), and the
segment splits there with the crossing vertex pinned to exactly 0.5 — so the band closes on a point,
not on a hairline sliver. Same-lead parts merge into one run; a run's band is its vertices forward
along the path, then straight back along the midline.
- A segment that only touches 50% at one end keeps the sign of its other end. A segment at exactly 50%
at BOTH ends is a genuine tie: it shades nothing and its span counts as tied, not as either side's.
- Events are pinned to the path at the probability interpolated at their t (probAt), drawn as a
card-filled diamond plus a thin full-height guide line. An event outside the sampled window is
dropped and counted — a marker with no path under it would have to invent its own probability.
- Feed hygiene, each repair counted and disclosed in a note under the chart: a non-finite t or
probability (or a duplicated t) drops the sample whole — a single series leaves nothing to keep; a
finite probability outside 0–1 is clamped to the nearer edge, because 1.04 is a claim of certainty,
not a different chart; unsorted input is re-sorted by t.
- Degenerate input must produce a picture, not an exception: zero samples fall through to the empty
branch even when status is "ready"; one sample draws an axis, a dot and a note (a band needs two).
- Favourite share is weighted by t, not by sample count — a feed that samples crunch time more densely
must not elect the wrong favourite. Report both shares in the legend, "wire to wire" when the lead
never changed, and the single biggest swing (with any event inside that segment named) in the summary.
- Reading it with a pointer: hit bands run to the midpoint between neighbouring stops, so anywhere in
the plot selects the nearest stop and fills a readout line ("35′: Flagrant 2 — Rockets 44%, Sharks
56% — Sharks favoured").
- Reading it with a keyboard: the plot is ONE tab stop. The stops are the samples PLUS the events
(an event landing exactly on a sample becomes that sample's news, never a duplicate stop), with a
roving tabindex. Arrow Left / Right step, Home / End jump to the ends, Page Up / Page Down jump
between events; movement clamps and never wraps. A hover is never the only path to an annotation.
If a feed update removes the stop that had focus, the tab stop takes focus back so it never lands
on <body>.
- Four states are first-class branches: loading (skeleton only), empty, error (retry button only when
onRetry is passed), ready.
Rendering & styling
- Semantic tokens only: var(--chart-1) for the home side, var(--chart-2) for the away side, plus
--card, --foreground, --muted-foreground, --border, --ring. No hex, no rgb(), no oklch() anywhere.
- Colour is never the only channel: the home band always sits ABOVE the midline and the away band
BELOW it, so the sides stay apart in greyscale by position alone. The line itself is stroked run by
run in the favourite's ink; events are diamonds — a shape channel; each side's name sits inside its
own half with a card-coloured text halo (paint-order: stroke) so the path can wander underneath.
- The y axis reads as "how sure, for whichever side owns that half": 100 / 75 / 50 / 75 / 100 percent.
Gridlines use the border token; the 50% midline is a dashed muted-foreground rule — a fact, not a
tick. The headline is the final probability in the winner's ink with "X to win · at t" under it.
- SSR-stable and deterministic: the viewBox always matches the width the geometry was computed for, so
the frame before the ResizeObserver reports is drawn scaled to fit rather than clipped, and once the
measured width arrives the scale is exactly 1. No Math.random and no Date.now at render — the
skeleton silhouette is a constant. Disconnect the observer on unmount.
- Accessibility: the svg is role="group", named by the card heading and described by an sr-only
summary sentence — NOT role="img", which is children-presentational and would silence the focusable
stops. Each stop option carries a full-sentence aria-label. The visible readout line is aria-hidden
(a focused stop already announces itself); a separate polite live region carries the POINTER-driven
readout, which no focus event announces. An sr-only table repeats every number and event; the
sr-only class goes on a wrapper div, never on the table.
- Motion: the only animation is the loading pulse plus a transition on the event diamond's stroke
width, both gated with motion-reduce. The chart is complete with animation off.
Customization levers
- Colour: swap --chart-1 / --chart-2 for any token pair that differs in hue AND lightness — team
brand tokens work well; the above/below-midline position channel already carries the distinction
if your theme only has one hue. Raise the band opacity (~0.16 default) for a heavier chart.
- Clock: formatTime is the single knob for the time dialect — "72′" for football, "Q4 2:31" for
basketball if you feed seconds, play numbers for baseball. Everything (ticks, readout, table)
prints through it.
- Density: height (140–480) and maxTicks are the layout knobs; drop the per-event guide lines when
a game has dozens of events and keep just the diamonds.
- Trim: hide the legend shares for a thumbnail embed; drop the headline block when the card sits
next to a scoreboard that already says who won.
- Extend: pipe onRetry into your fetch layer; append live samples as they arrive — the model,
shares and stops all rebuild from the same contract, so a live game and a recap are the same
component in two moments.Concepts
- Favourite run — one unbroken stretch where the same side is favoured. Runs, not samples, are what gets shaded, and they start and end exactly where the favourite does: at an interpolated 50% crossing or at a dead-even plateau.
- Midline as sides — 50% is not a gridline, it is the border between two teams. Home is always shaded above it and away below, so position carries the distinction even in greyscale — colour is a reinforcement, never the only channel.
- Event pinned to the path — an annotation's y is not styling, it is
probAt(t): the diamond sits on the line at the probability the model held at that moment, which is what lets a reader match "flagrant foul" to "the 11-point drop". - Stop — anything the reader can land on: every sample and every event. Making events stops is what puts "what happened there" one Page Down away instead of behind a hover.
- t-weighted share — how much of the game each side was favoured, measured in clock time, not in samples. A feed that samples crunch time twice as often would otherwise elect the wrong favourite.
- Biggest swing — the largest single move between two neighbouring readings, with any event inside that segment named in the summary: the machine's answer to "when did the game turn?".
xG Timeline
A cumulative expected-goals step chart — two team lines over 90+ minutes, emphasized goal dots with minute labels, a half-time rule, and final xG totals in the header.
Training Load ACWR
A four-state training-load chart: daily load bars, 7-day acute and 28-day chronic rolling averages, and the acute:chronic ratio with a sweet-spot band and a high-risk zone — all rolling math done inside the component.