Charts

Baseball Win Probability

A four-state baseball win probability replay — a stepped line over the half-inning axis with leverage shading behind it, key plays called out by the swing they were worth, driven by a real play / pause / step / scrub transport.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, Pause, Play, RotateCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartBaseballWinProbabilityData,
  ChartBaseballWinProbabilityHalf,
  ChartBaseballWinProbabilityPlay,
} from "./chart-baseball-win-probability.contract"

export interface ChartBaseballWinProbabilityProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "title">,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartBaseballWinProbability" card — a
playable win probability replay for one baseball game, in hand-rolled SVG (no
chart library, no animation library), with zod and lucide-react.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string;
    firstPitchLabel?: string;
    home: { label: string; code?: string };
    away: { label: string; code?: string };
    plays: { id: string;
             inning: number;                 // 1-based, extras keep counting
             half: "top" | "bottom";         // visitors bat in the top
             outs?: number;                  // AFTER the play, 0-3
             homeWinProbability: number;     // AFTER the play, 0-1
             leverageIndex?: number;         // ENTERING the play, 1.00 = average
             homeScore: number; awayScore: number;
             batter?: string; description?: string }[];
    pregameHomeWinProbability?: number;      // where the line starts, default .5
    regulationInnings?: number;              // default 9
    keyPlayWpa?: number;                     // default .100
    frameMs?: number }
  refined so play ids are unique — two plays on one id share a React key and a
  chip, and the second quietly leaves the replay.
- Props = z.infer of the schema plus variant?, value?, defaultValue?,
  onValueChange?, onRetry? and className. No hand-written parallel interface.
- Probability is a probability (0-1), never a percentage, because every swing
  on the card is the difference of two consecutive values of it. The clock is
  the inning, not a timer: nothing in the component reads Date.now.

Geometry, in exported pure functions beside the component
- buildBaseballWinProbability(plays, options) does the whole layout in one
  pass and returns the numbers the picture is made of, so a test can print
  them: ordered marks, unusable/adjusted counts, axisEnd, innings,
  maxPlaysInHalf, peakSwing, keyPlays, biggest, highLeveragePlays.
- Order is inning, then top before bottom, then the order the feed sent them
  in, so two plays in the same half never trade places between renders.
- The x unit is the HALF-INNING: half-inning h owns [h, h+1], and the m plays
  logged in it sit at h + (j+1)/m, so the last one lands on the boundary. The
  axis stays innings however lopsided the logging is. axisEnd is
  max(regulationInnings * 2, last half-inning + 1) — extras stretch it, the
  playhead never shrinks it, and both scales are fixed to the WHOLE game so a
  paused frame looks the same as the finished one.
- wpa = this play's probability minus the previous one (pre-game for the
  first); |wpa| at or above keyPlayWpa makes it a key play; biggest is the
  largest |wpa| in the game.
- leverageBand(li): under 0.85 is low, 0.85 up to 2.00 is medium, 2.00 and up
  is high — the same thresholds every box score quotes, printed in the legend
  so the shading is never a mystery gradient.
- stepPath(points, x, y, end, start) draws step-after: flat TO the play, jump
  AT it, never interpolated, because nothing happens between two plate
  appearances. `end` is the playhead, so the line stops where the replay is.
- swingTicks(peak) gives the swing view a symmetric domain, so neither side
  gets more room. Plus ordinal, halfInningLabel ("Top 7th"), halfInningCode
  ("B9"), outsLabel ("none out" / "2 out" / "3 out, side retired"),
  formatWpa ("+.226", "-.292", ".000" — signed, three decimals, no leading
  zero, the way baseball writes a rate), formatWinProbability ("61.8%") and
  formatLeverage ("4.92").

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
  mirrors the ready layout — line score, plot, transport — so the card keeps
  its height), empty (a dashed path glyph plus copy, reused with a stated
  reason when a ready payload has nothing drawable), error (message, and a
  "Try again" button only when onRetry exists), ready.
- The playhead is an injected frame index, never a wall clock: 0 is the first
  pitch, n the moment just after the nth play. Controlled and uncontrolled
  both work through the value / defaultValue / onValueChange triple; the
  rendered frame is always derived and clamped, so a shorter feed or a wild
  controlled value can never point at a play that does not exist. A paused
  chart at frame n renders byte-identical every time.
- Transport is real UI with real buttons: step back, play/pause (which turns
  into replay-from-the-first-pitch at the last out), step forward, a labelled
  <input type="range"> scrubbing one play per step with a spoken
  aria-valuetext, and a frame counter. Playback is ONE setTimeout per frame,
  cleared on pause, on scrub, on unmount and whenever the tab hides —
  never an interval, never a loop left running off-screen.
- Reaching the last out is derived (frame >= plays), not a state write from an
  effect: the button simply says replay and the timer has nowhere to advance.
- No control is ever natively disabled: at either end the step buttons carry
  aria-disabled plus a handler guard, so nothing vanishes from under a finger
  and focus never lands on <body>.
- Key plays get a chip strip under the transport — roving tabindex, one tab
  stop, arrows / Home / End walk it, Enter jumps the playhead to the frame
  that CONTAINS that play, so the focused chip cannot unmount beneath the
  ring.
- prefers-reduced-motion: playback keeps working with motion off. The only
  tween on the card is the playhead rule, gated behind motion-safe, so with
  motion reduced it simply appears at the new play; everything else already
  appears and disappears one play at a time.
- A sr-only aria-live="polite" region speaks the new frame: half-inning, outs,
  the play in the feed's own words, the running score, the new probability and
  the swing it was worth.

Rendering & styling
- One svg, role="img" with a one-sentence aria-label naming both clubs, the
  clock it is paused at, the score and the probability. Width is measured with
  a ResizeObserver attached through a callback ref (disconnected on unmount);
  the height is fixed.
- Probability view: the area between the line and the 50% rule, clipped above
  and below and filled in the leading side's token at low opacity, with the
  line itself in stroke-foreground. Both halves of the axis carry a text label
  ("<home> win" at the top, "<away> win" at the bottom) so colour is never the
  only clue. y ticks are 0 / 25 / 50 / 75 / 100 percent; x rules and numbers
  are innings, two half-inning slots each.
- Leverage shading: each play owns the span that led up to it, tinted by its
  band — low unshaded, medium a light var(--chart-4) wash, high a stronger
  wash PLUS a diagonal hatch pattern, so the tensest spans survive greyscale
  and print. Spans past the playhead stay bare; a play with no usable leverage
  index stays unshaded and is counted, never guessed at.
- Marks: every played play is a small dot on its step; a key play is a diamond
  in the gaining side's token with a direct label ("B9 +.765"), never colour
  alone. Two key plays can land three pitches apart, so an exported
  spaceKeyLabels pass hands the space to the biggest swing first and drops any
  label that would collide — the play keeps its diamond, its tooltip, its chip
  and its table row. Labels are clamped inside the plot, never into the
  playhead's strip. The swing variant redraws the same replay as one column per
  play from a zero rule, home gains up and visitor gains down, both labelled.
- Everything else is text: line score with running probability per side, a big
  clock readout ("Bot 9th") over outs and leverage, the latest play in one
  truncating line with a title attribute, a biggest-swing callout, a legend
  for the three leverage bands and the key-play diamond, a footnote counting
  every repair, and an sr-only table of every play.
- Colour only ever comes from semantic tokens (border, muted, card,
  foreground, primary for the range accent, var(--chart-1) home,
  var(--chart-2) away, var(--chart-4) leverage); no hex, rgb() or oklch()
  anywhere. cn() merges className, the root spreads remaining props and
  carries data-status.

Edge cases the ingest repairs and then states out loud
- Non-finite inning or probability: the play is dropped and counted.
- Probability outside 0-1, inning below 1, outs outside 0-3, negative or
  unreadable leverage (treated as missing, never as zero), leverage above 12,
  fractional or negative runs: pulled back to the edge and counted.
- A missing run total carries the previous one forward rather than blinking to
  zero mid-inning.
- Zero plays, one play (two frames), one play in a half-inning, a game that
  runs to the 11th, a batter name or a description that cannot fit (truncated
  with a title attribute, never overflowing) and a play worth .000 all draw
  without breaking the geometry.

Customization levers
- Tempo: frameMs (clamped 150-5000) sets how fast a replay walks; drop it to
  200 for a fast recap, raise it for a coaching walkthrough.
- What counts as key: keyPlayWpa moves the diamond-and-chip bar; .050 fills
  the strip with chips, .200 keeps only the game-deciders.
- Leverage bands: LEVERAGE_LOW / LEVERAGE_HIGH and the opacity map are three
  constants — re-band them for a league whose leverage runs hotter, or set the
  medium opacity to 0 to shade only the high-leverage spans.
- Axis: regulationInnings re-scales the whole card for seven-inning
  double-headers or six-inning youth games; extras always stretch it further.
- Views: variant="probability" or "swing" from the same contract.
- Tokens: TEAM_COLOR and LEVERAGE_FILL are re-keyable to any chart tokens,
  e.g. real club colours from the host app.
- Blocks: line score, latest-play line, biggest-swing callout, legend and
  footnote are independent siblings — drop any of them for an embedded mini
  replay, or replace the header with your own scoreboard.
- Domain: the only baseball-specific things are the half-inning axis and the
  vocabulary; swap halfInningLabel and the axis unit and the same engine
  replays any sport whose clock is a discrete sequence of turns.

Concepts

  • The inning is the clock — baseball has no timer, so the x unit is the half-inning: each one owns a slot on the axis and the plays logged inside it share that slot evenly. An inning where nine men bat and an inning that goes down in order occupy the same width, which is what keeps the picture about the game rather than about how chatty the feed was.
  • Step-after, never interpolated — win probability holds flat through a plate appearance and jumps at its result. A sloped line between two plays would draw a moment that never existed; the step is the honest shape, and it is also what makes the key-play diamonds land exactly on the jump they caused.
  • Derived playhead, injected frame — playback state is one integer, and the same integer arrives from a timer, a step button, the scrubber or a chip jump. Controlled and uncontrolled both work through value / defaultValue / onValueChange, the rendered frame is always clamped from that, and a paused frame renders byte-identical every time — which is what makes the card screenshot-stable and SSR-safe.
  • Leverage is a band, not a gradient — the shading behind the line uses the three bands every box score quotes: under 0.85 is low and stays bare, 0.85 up to 2.00 gets a light wash, 2.00 and up gets a stronger wash plus a hatch, so the tensest spans survive greyscale and print. A play whose feed omitted leverage stays unshaded and gets counted, because a guessed 1.00 would be a claim about the game.
  • Key plays are earned, not curated — a play is key when it moved win probability by at least the stated bar (.100 by default), so the diamonds, the chips and the caption all come from one number the reader can see. The chip strip is a roving-tabindex toolbar, and jumping always lands on the frame that contains the play, so the focused chip can never unmount under the ring.
  • Repairs are counted out loud — unreadable plays dropped, probabilities and leverage clamped, outs pulled back into 0-3, a lost run total carried forward, and each count printed under the chart. A replay that silently loses a play is lying about the swing on both sides of it.

On This Page