Charts

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.

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 {
  ChartBaseballSprayBall,
  ChartBaseballSprayBattedBall,
  ChartBaseballSprayData,
  ChartBaseballSprayFence,
  ChartBaseballSprayOutcome,
} from "./chart-baseball-spray.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartBaseballSpray" chart — batted balls
sprayed across a to-scale baseball field in hand-rolled SVG (no chart library,
no animation library), with a real transport that replays a stretch of games
ball by ball, and zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; batter?: string; bats?: "R" | "L"; park?: string;
    fence?: { line: number; gap: number; center: number };
    balls: { sprayAngle: number; distance: number;
             outcome: "out" | "single" | "double" | "triple" | "home-run";
             battedBall?: "ground-ball" | "line-drive" | "fly-ball" | "popup";
             exitVelocity?: number; launchAngle?: number;
             inning: number; half: "top" | "bottom";
             date?: string; opponent?: string; note?: string }[];
    trail?: "accumulate" | "single"; rays?: boolean;
    ballIndex?: number; defaultBallIndex?: number; stepMs?: number }.
- Props = z.infer of that schema plus onBallIndexChange?, onRetry? and
  className. No hand-written parallel interface.
- sprayAngle is Statcast's: −45 is the left-field line, 0 is straight over
  second base, +45 is the right-field line; distance is feet from home plate.
- Two schema refinements earn their keep: ready needs at least one ball, and
  ballIndex together with defaultBallIndex is refused — that pair is the
  classic controlled/uncontrolled bug where the caller thinks it owns the
  playhead while the component keeps a second copy and wins.

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
  mirrors the ready silhouette — field, read-out, transport, rail — so the card
  keeps its height), empty (the park drawn faint, plus copy), error (message,
  and a Try again button only when onRetry exists), ready.
- The playhead is an index into balls[], and it is DERIVED, never a wall clock:
  index = clamp(controlled ?? uncontrolled ?? lastIndex, 0, lastIndex). Both
  legs of the triple work — ballIndex + onBallIndexChange for a parent that
  owns it, defaultBallIndex for a card that owns itself. Uncontrolled state
  holds null for "parked at the last ball", so a feed that grows stays parked
  rather than stranding the playhead at a stale index.
- NO autoplay. The first render parks on the last ball, so a screenshot, an SSR
  paint or a docs thumbnail already shows the whole stretch; pressing play from
  there rewinds to the first ball and fills the field in again. A paused card
  at ball N renders byte-identical every time: no Date.now, no Math.random and
  no measurement anywhere in the render path.
- Playback is ONE window.setTimeout per step (default 850 ms, clamped
  150–5000), owned by one effect keyed on the playhead, the hold and whether
  playback is running — so a pause, a step, a scrub or an unmount all clear it
  before anything else is scheduled. A visibilitychange listener pauses when
  the tab hides, leaving the button showing Play so coming back is one press.
  Reaching the last ball is derived (nothing left to advance to), never a
  setState inside an effect.
- Transport row of REAL buttons, all keyboard operable with focus-visible
  rings: restart, previous, play/pause (which becomes replay at the end and
  says so in its aria-label), next — plus a labelled native range input whose
  aria-valuetext speaks "Ball 7 of 20, Bot 4th · Jul 18 · vs LAD". Never
  `disabled` on a transport control: a button that stops being focusable at the
  last ball drops the keyboard user onto <body> mid-sequence, so each stays
  reachable with aria-disabled and a handler that refuses.
- Under the transport sits the rail: one small button per ball, roving tabindex
  in a role="listbox", arrows / Home / End walk the stretch and move focus with
  the playhead, each option carrying the full sentence as its aria-label. That
  rail is the keyboard equivalent of the marks, which are pointer-only (hover
  shows a tooltip, click seeks).
- prefers-reduced-motion: playback is discrete by construction — marks appear
  at each step, nothing tweens or flies — and the only transitions on the card
  (mark opacity, control colours) are gated behind motion-safe:, so the feature
  works identically with motion off.
- A sr-only aria-live="polite" line announces the ball under the playhead:
  number, half-inning, game, outcome, batted-ball type, distance, direction and
  exit velocity.
- The running line accumulates to the playhead only: hits per batted ball
  printed the way baseball spells an average (.350, three decimals, no leading
  zero), total bases, home runs, average exit velocity to one decimal, and
  hard-hit rate (95+ mph, Statcast's line) over the balls that were actually
  measured. The legend counts the same slice, so nothing spoils a ball the
  playhead has not reached.
- Repairs are counted out loud, never silent: a non-finite angle or distance
  keeps its place in the sequence, the running line and the table but draws no
  mark; a ball past the drawn ground or past the foul margin is pulled onto the
  edge and ringed with a dashed circle; a game date nothing can parse is
  printed exactly as the feed sent it; park dimensions outside 250–450 ft are
  refused for a league-average wall. Each count prints in a visible note.

Rendering & styling
- Field geometry in real feet, so every number in the picture is checkable:
  foul lines 45° either side of dead centre, 90 ft base paths (second base
  therefore 90√2 = 127.3 ft out), pitching rubber 60.5 ft, mound circle 9 ft,
  and the infield dirt an arc of 95 ft radius struck from the rubber — solved,
  not eyeballed, so it meets each foul line at 127.6 ft.
- The wall is three numbers — line, gap (22.5° off centre), centre — smoothed
  between with a smoothstep so the power alley is a curve rather than a kink,
  then sampled every 1.5° into one polyline. No SVG arc flags to get wrong.
- Home plate sits bottom-centre, centre field straight up; a projection helper
  turns (spray angle, distance) into user units and every line, base and mark
  goes through it. Fair territory carries the bg-muted wash and the infield a
  slightly stronger muted-foreground one; foul ground and everything past the
  wall stay card-coloured, so a ball out of the park visibly leaves it.
- Outcome is a SHAPE first — filled circle, triangle, diamond and star for
  single / double / triple / home run, hollow circle for an out — with colour
  repeating the same split (var(--chart-1..4), outs in muted-foreground) and
  the ball under the playhead labelling itself on the field ("HR · 412 ft"),
  so the plot survives greyscale and never asks the reader to hold a legend in
  their head. Dashed range rings at 200 and 300 ft keep distance readable.
- Every layout number lives in an exported pure function beside the component —
  project, clampSpray, fenceRadiusFt, fencePoints, infieldCornerFt,
  infieldPoints, rangeArcPoints, starPath, sprayPhrase, formatHalfInning,
  formatAverage, summarizeSpray — so a test can print the same numbers the
  picture is made of.
- The static figure is role="img" with a one-sentence aria-label; an sr-only
  table lists every ball with its game, half-inning, outcome, direction,
  distance, exit velocity, launch angle, batted-ball type and note.
- Colour only ever comes from semantic tokens (card, muted, border, popover,
  primary for the range accent, var(--chart-*)); no hex, rgb() or oklch()
  anywhere, field markings included. cn() merges className, the root spreads
  the remaining props and carries data-status.

Customization levers
- The park: fence { line, gap, center } is the whole outfield shape — pass your
  own for a real stadium, or widen SURFACE_R for a feed that tracks projected
  distances past 470 ft.
- Crop and clutter: FOUL_MARGIN_DEG sets how much foul ground is drawn, the
  200/300 ft range rings are one array, and the read-out, legend, scrubber and
  rail are independent siblings — drop any of them for an embedded mini chart.
- Tempo: stepMs is the hold per ball (clamped 150–5000); raise it for a lobby
  board, lower it to flick through a whole season.
- Marks: trail="single" for one ball at a time, rays for a line from the plate
  to each landing spot, and DOT_R / HALO_R / HIT_R size the mark, its halo and
  its (much larger) pointer target independently.
- Tokens: OUTCOME_TOKEN maps outcome to var(--chart-*) and OUTCOMES maps it to
  a glyph and a tag — re-key either to your own palette or scoring vocabulary.
- Domain: the direction bands, the hard-hit threshold and the half-inning clock
  are three small pure functions; swap them and the same engine plots cricket
  wagon wheels or softball without touching the transport.

Concepts

  • The playhead is derived, not stored — one index, clamped from the controlled prop, the uncontrolled slot or "the last ball", is the only playback state. The picture, the running line, the clock readout, the scrubber thumb and the rail all read it, so play, step, scrub and keyboard seeking can never disagree about what has been hit.
  • Parked at the end, never autoplaying — the first render shows the whole stretch on the field, so a screenshot or an SSR paint is already the full story; play from there means "rewind and watch it fill in". That is also what makes a paused card at ball N render byte-identical every time.
  • The sport keeps its own clock — position on the timeline is spoken as half-inning and game (Bot 4th · Jul 18 · vs LAD), not as a percentage or a frame number, because that is the unit the reader already thinks in.
  • Shape carries the outcome, colour only repeats it — filled circle, triangle, diamond and star for the four hit types, hollow circle for an out, plus a direct label on the ball under the playhead. The chart survives greyscale, and the legend is a convenience rather than a decoder ring.
  • Geometry is solved, not eyeballed — the 95 ft infield arc is intersected with the foul line to find where the dirt ends (127.6 ft), and the wall is a smoothstep through three park dimensions sampled into a polyline. Every one of those numbers comes out of an exported pure function, so a test can print exactly what the picture is made of.
  • Repairs are counted out loud — a 620 ft home run, a ball hit 71° foul, a missing landing spot, a date nothing can parse: each one is clamped or kept in the sequence and then stated in a visible note. A spray chart that quietly drops contact is lying about the average printed above it.

On This Page