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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-baseball-win-probability.jsonPrompt
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.
Baseball Spray Chart
A batted-ball spray chart on a to-scale field — direction and distance from home plate, outcome by shape, and a transport that replays a stretch of games ball by ball while the running line fills in.
Hockey Rink Shots
A four-state full-sheet hockey shot map in hand-rolled SVG — danger zones from the slot outward, marks that carry the team in colour and the outcome in shape, period-and-clock playback, and a shot-attempt differential rail under the ice.