Baseball Strike Zone
A four-state strike zone from the catcher's view — pitch type by colour, outcome by shape, called-strike-chance shading, and a transport that replays the at-bat pitch by pitch with the count moving as it goes.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-baseball-strike-zone.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBaseballStrikeZone" card with zod and
lucide-react (Play, Pause, ChevronLeft, ChevronRight, RotateCcw). No charting
library: the plot is hand-rolled SVG, because the subject is a real playing
surface in real proportions, and a generic chart would draw a scatter plot with
a rectangle on it.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title; game?; pitcher?;
batter?; bats?: "R" | "L"; inning: int >= 1; half: "top" | "bottom";
outs: 0..2; zone: { bottom, top } (feet above the ground, this batter's own
sz_bot / sz_top); count?: { balls: 0..3, strikes: 0..2 } before the FIRST
pitch; pitches: { location?: { x, z }, type, velo, spin?, outcome,
calledStrikeChance?, note? }[]; result?; trail?: "cumulative" | "single";
pitchIndex?; defaultPitchIndex?; pitchMs? }
- Coordinates are the Statcast frame: x is feet from the middle of the plate,
positive toward the CATCHER'S right; z is height above the ground as the ball
crosses the front of the plate. A real feed drops straight in.
- `location` is optional on purpose: a pitch that was thrown but not tracked
stays in the sequence, in the count and in the table — it simply has no mark.
- pitch type is an enum of the eight a broadcast names (four-seam, sinker,
cutter, slider, sweeper, curveball, changeup, splitter) and the component
derives the tag that fits on a chip (FF, SI, FC, SL, ST, CU, CH, FS).
- outcome is the six a plate appearance is made of: ball, called-strike,
swinging-strike, foul, in-play, hit-by-pitch. They are what advances the count.
- Refinements: "ready" needs at least one pitch; pitchIndex and defaultPitchIndex
are mutually exclusive (both at once is the controlled/uncontrolled bug where
the caller thinks it owns the playhead and the component quietly wins).
- Props = z.infer of the schema plus onPitchIndexChange?, onRetry?, className and
the native div props; forwardRef to the card root.
Geometry — every number is an exported pure function or constant, so a test can
print the same figures the picture is made of
- PLATE_HALF_FT = 8.5/12 (home plate is 17 inches wide), BALL_RADIUS_FT = 1.45/12,
ZONE_HALF_FT = plate half + one ball radius: the zone an umpire actually calls
is judged by any part of the ball catching the black.
- SURFACE is the drawn box in feet (x -2.1..2.1, z -0.45..4.95) and FT = 100 user
units per foot, so VIEW is 420x540. project(x, z) is the one map every line,
band and mark goes through, and it flips y because SVG grows downward.
- normalizeZone(zone): a zone shorter than six inches, upside down, underground
or above the head is not a zone — fall back to a league-average 1.5-3.5 ft one
and SAY SO on the card, because every band, ring and percentage is measured
from those two numbers.
- edgeDistanceFt(x, z, zone): signed feet to the zone boundary — the straight-line
distance to the box outside it, the distance to the nearest edge inside it,
zero on the line. calledStrikeChance = logistic of that over CALL_FALLOFF_FT
(0.09 ft): 50% on the line, ~20% one ball outside, ~5% three inches out.
- The shading IS that function, not a decoration: contourOffsetFt(p) inverts the
logistic, so the 90 / 50 / 10 percent contours are the rulebook box inset or
outset by that many feet. Draw them as three stacked rects — outset corners get
rx equal to the offset (the level set really is round out there), inset corners
stay square (it really is square in there) — and label each contour in the plot.
A per-pitch calledStrikeChance from the caller's own umpire model overrides the
number, never the picture, so the two can never disagree by more than the model.
- attackZone(x, z, zone) buckets a pitch into Statcast's Heart / Shadow / Chase /
Waste by the larger of |x|/zoneHalf and |z-middle|/zoneHalf-height, with the
ring cut-offs in one exported constant (Statcast cuts its rings in inches; swap
the three numbers to match it exactly).
- locationPhrase(x, z, zone, bats) is the fan's sentence: "down and away", "up and
in", "middle-middle". In the catcher's view a right-handed batter stands to the
catcher's LEFT, so for him inside is negative x. Without `bats` the phrase stays
catcher-relative rather than guessing which box the batter is standing in.
- countsForPitches(pitches, start) returns the count BEFORE each pitch by the
rulebook: a ball adds a ball, either strike adds a strike, a foul adds one only
below two strikes, in-play and hit-by-pitch end the plate appearance. Cap the
printed count at 3-2 so a feed that keeps going past ball four still prints a
count that can exist.
- atBatResult(pitchesSoFar, start) derives strikeout looking / strikeout swinging
/ walk / hit by pitch / ball in play, and returns null while the at-bat is live.
Behavior
- The playhead is an index into `pitches`, DERIVED and clamped on every render —
a shorter payload must never leave it pointing past the end. A paused card at
pitch N renders identically every time, which is what makes it SSR-safe and
screenshot-stable. Nothing reads a wall clock.
- Controlled and uncontrolled both work: pitchIndex + onPitchIndexChange, or
defaultPitchIndex and the component owns it. onPitchIndexChange fires on every
move — playback, step, restart, rail. A controlled parent that ignores the
callback simply stalls the sequence; it can never loop.
- Transport is REAL BUTTONS: restart, previous pitch, play/pause, next pitch, plus
a rail of one chip per pitch to jump anywhere. Never a gesture-only scrubber and
never an autoplay you cannot stop — the card starts paused.
- Playback is a self-rescheduling setTimeout, one timer owned by one effect,
cleared in that effect's cleanup; the effect depends on the playhead and the
step duration only, and reaches the advance through a latest-value ref so a
chatty parent cannot restart the timer on every render. Reaching the last pitch
is DERIVED (`running = playing && !atEnd`), never a setState inside an effect;
there the play button becomes a replay button and says so.
- A visibilitychange listener pauses when the tab hides, so nothing loops
off-screen; the listener is removed with the effect.
- The clock is the sport's own: the count (0-0 → 2-2) big in the header, with
"Top 7th · 2 out" under it, and "Pitch 4 of 6" beside the transport. The at-bat
RESULT is revealed only when the playhead reaches the last pitch — a replay that
spoils itself is not a replay.
- trail: "cumulative" (default) keeps every pitch already thrown, dimmed and
numbered, so the sequence reads as a sequence; "single" draws only the pitch
under the playhead for a board read from across the room.
- Keyboard: the rail is role="listbox" with roving tabindex (the selected chip is
the only tab stop). ArrowRight/ArrowDown next, ArrowLeft/ArrowUp previous, Home
first, End last; every one of them pauses playback, moves the playhead and moves
focus with it. preventDefault only for the keys handled, so Tab still leaves.
- Edge cases, all of them visible rather than silent: a pitch outside the drawn
surface is CLAMPED to the edge, ringed with a dashed circle and counted in a
note; a pitch with no location is counted in a second note and still moves the
count; a non-finite velocity prints "velo not tracked"; an unusable zone falls
back and says so; long names truncate with a title attribute.
- Transport buttons never use native `disabled` — aria-disabled plus a handler
guard, so a control that becomes unavailable at the last pitch cannot vanish
from under the keyboard and drop focus onto the body.
Rendering & styling
- Semantic tokens only: bg-card panel, stroke-foreground for the rulebook box,
stroke-muted-foreground for the nine cells, stroke-border and fill-muted for the
ground line and home plate, fill-muted-foreground at low opacity for the three
call contours, bg-primary for the selected chip. Pitch type colour cycles
var(--chart-1..5) in the order the arm first shows each pitch. No hex anywhere,
plate markings included.
- Colour is never the only channel: outcome is a SHAPE (hollow circle = ball,
filled circle = called strike, X = swinging strike, hollow triangle = foul,
diamond = in play, square = hit by pitch), every mark is printed with its pitch
number beside it, and the read-out under the plot names the type, the location
phrase, the attack zone and the called-strike chance in words.
- Marks are drawn one baseball across at true scale, so the plot answers "would
that have caught the corner?" honestly. Number labels flip to the other side of
a mark near the right edge and are stroked in the card colour with
paint-order: stroke, so they stay legible over the shading.
- Accessibility: the plot is one role="img" with a generated one-sentence
aria-label; the transport announces every move through a polite live region;
a visually hidden data table repeats every pitch, count, location, attack zone,
chance and outcome. The sr-only wrapper is a div — a bare sr-only table keeps
auto table layout, ignores width:1px and drags the page sideways.
- Motion: every tween is behind motion-safe. With motion off the marks simply
appear and disappear at each step and playback works exactly the same; nothing
needs an in-flight animation to become readable.
- Four first-class branches share one shell (title, matchup, game line): loading
is a skeleton in the ready layout, empty draws the zone itself with a line of
copy, error offers Try again only when onRetry exists, ready is the plot.
Customization levers
- Zone model: CALL_FALLOFF_FT is the umpire's sharpness — smaller is a robot
strike zone, larger is a generous one. CALL_CONTOURS picks which contours are
drawn; drop to one for a cleaner board.
- Attack rings: ATTACK_RINGS (heart/shadow/chase) are ratios of the zone's own
half-size; switch them to Statcast's inch cut-offs if you report against it.
- Surface: SURFACE plus FT is the whole projection. Tighten x to ±1.6 ft for a
close-up of the plate, or raise zMax for a chart that has to hold eye-level
fastballs.
- Mark size: BALL_R is one real baseball — grow it for a broadcast overlay, shrink
it for a dense one-inning sequence.
- Trail: "cumulative" vs "single"; or slice `marks` to a rolling window of the last
three pitches for a tunnelling view.
- Pace: pitchMs (clamped 300–8000) is one pitch. Add a speed segmented control the
way a bar race does if the sequence is a whole inning rather than an at-bat.
- Palette: colour by pitch type is one Map — pin a fixed token per type across the
whole app instead of cycling per at-bat, and the legend follows.
- Sub-blocks that can go without touching the geometry: the game line, the spin
figure, the notes row, the pitch-type legend. The outcome legend, the numbers on
the marks and the two clamp/untracked notes should stay — they are what keeps
the picture honest.
- Autoplay is deliberately absent. If a hero card must start running, fire the
play state from an intersection observer and leave the pause button.Concepts
- Derived playhead, never a wall clock — the card takes an instant (an index into the pitches) and clamps it on every render. Playback is just something that moves that index on a timer, so a paused card at pitch 4 draws the same bytes on the server, in a screenshot and after a reload. Anything that read
Date.now()would make the picture unreproducible. - The transport is UI, not a gesture — restart, step back, play/pause, step forward, and one chip per pitch to jump. Buttons that become unavailable use
aria-disabledand a handler guard rather thandisabled, because a control that stops being focusable at the last pitch drops the keyboard user onto the body mid-sequence. - Shading that is a function, not a wash — called-strike chance is a logistic on the signed distance to the zone boundary, and the drawn contours are that same function inverted, so the 50% line is exactly the rulebook box and the 10% band is exactly one inversion away from it. The picture and the printed percentage cannot drift apart.
- Outcome is a shape, pitch type is the colour — hollow circle, filled circle, X, triangle, diamond, square, each also printed with its pitch number and named in the read-out. The chart survives greyscale, a projector and a colour-blind reader, and the legend is a convenience rather than the only key.
- Clamped, counted, never dropped — a pitch at the backstop is pulled to the edge, ringed with a dashed circle and confessed in a note; an untracked pitch keeps its place in the sequence and its effect on the count. A chart that quietly loses pitches lies about the at-bat as well.
- The count is the clock — baseball measures this moment in balls and strikes inside a half-inning, so that is the read-out, and the result of the plate appearance is withheld until the playhead reaches the last pitch.
- Reduced motion keeps the function — every tween sits behind
motion-safe, so with motion off the marks appear and disappear step by step and playback, scrubbing, the count and every figure stay exactly as they are.
Chart Volleyball Attack Lanes
A four-state volleyball attack-lane replay in hand-rolled SVG — tapered corridors run out of the setter's hands to each hitter and across the net, thickening swing by swing as the match replays under a set / score / rotation transport.
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.