Charts

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.

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 {
  ChartBaseballStrikeZoneCount,
  ChartBaseballStrikeZoneData,
  ChartBaseballStrikeZoneOutcome,
  ChartBaseballStrikeZonePitch,
  ChartBaseballStrikeZonePitchType,
  ChartBaseballStrikeZoneZone,
} from "./chart-baseball-strike-zone.contract"

Installation

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

Prompt

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-disabled and a handler guard rather than disabled, 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.

On This Page