Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildWinProbabilityModel,
  buildWinProbabilityStops,
  pickTickIndices,
  prepareWinProbability,
  probabilityPath,
  runBandPath,
  stopBands,
  type ChartWinProbabilityData,

Installation

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

Prompt

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?".

On This Page