Charts

Serve Speed Replay

A four-state tennis serve-speed replay in hand-rolled SVG — a 240° dial whose needle kicks to each serve while a fixed-band histogram builds up one tick at a time, with play/pause, an index scrubber, speed gears, a persistent fastest-so-far ring marker and a scrub-time readout row.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServeSpeedReplayData,
  ChartServeSpeedReplayServe,
  ChartServeSpeedReplayUnit,
} from "./chart-serve-speed-replay.contract"

export interface ChartServeSpeedReplayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartServeSpeedReplayData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-speed-replay.json

Prompt

Build a React + TypeScript + Tailwind "ChartServeSpeedReplay" chart — a tennis
serve-speed replay in hand-rolled SVG (no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    unit: "km/h" | "mph";
    serves: { speed: number > 0; serve: 1 | 2; point?: string }[] }.
- Props = z.infer of the schema plus title?, bandWidth?, onRetry?, emptyState?
  and className; the root forwards its ref and spreads remaining div props.
  No hand-written parallel interface.
- serves is RAW and CHRONOLOGICAL — order is the story being replayed. The
  unit is echoed verbatim on the dial ticks, the readout, the band axis, the
  tooltip and the table; the chart never converts.

Behavior
- Four first-class branches in one bg-card panel: loading (skeleton keeping
  the dial + histogram + transport silhouette), empty ("no serves tracked",
  a dashed needleless dial — reused when a ready payload has nothing usable),
  error (message + "Try again" only when onRetry exists), ready.
- One timeline index: `shown` = how many serves are on the board (0..n).
  Everything is derived from it — the dial's current serve, the histogram's
  visible ticks, the fastest-so-far marker, the readout row.
- NO autoplay. Ready parks at the END: full histogram, needle resting on the
  last serve. Pressing Play rewinds to 0 and replays forward; reaching the end
  stops playback for real (a later scrub must not resume it).
- Transport: a Play/Pause toggle (aria-pressed + aria-label that says
  play / pause / replay), a labeled native range scrubber over the serve
  index (aria-valuetext speaks "serve k of n, speed, delivery"), and a
  1×/2×/4× speed radiogroup. Scrubbing pauses playback.
- Playback is one requestAnimationFrame loop with timestamp deltas — one
  serve per beat (~750 ms at 1×, divided by the gear), background-tab gaps
  clamped, cancelled on pause, gear change, end and unmount. No setInterval.
- The needle chases the current serve's speed with an rAF exponential ease
  that settles within a beat and simply retargets when the next serve lands
  mid-flight. First paint is deterministic (parked, nothing animating).
- prefers-reduced-motion (matchMedia + change listener, removed on unmount):
  the needle sets instantly per step, histogram ticks appear without the
  fade/rise tween (motion-reduce:transition-none), the emphasis ring stops
  pulsing (motion-reduce:animate-none) but stays visible, skeleton pulses
  stop. Playback itself still works — motion is reduced, not function.
- Binning mirrors chart-serve-speed-bands: fixed-width bands (default
  10 km/h or 5 mph, clamped 1–100) on edges snapped to round multiples,
  left-closed/right-open with the top edge closed; if the range would draw
  more than 24 bands the width widens by a whole multiple and the footnote
  says so. Band edges, dial ceiling (next round major step above the fastest
  serve) and the tallest stack are all computed from the FULL sequence, so
  the frame never moves while the histogram is still filling.
- Every histogram unit is always mounted; the scrub position only drives
  opacity/translate, so a new tick fades in via CSS transition. Units stack
  bottom-up in arrival order with 2px card gaps; the current serve's unit
  wears a pulsing primary emphasis ring.
- Fastest-so-far: a thin persistent tick on the dial ring at the fastest
  shown speed, climbing during playback. A throttled polite live region
  announces each new record ("New fastest serve: …"), plus pause and finish;
  announcements fire from the rAF loop and handlers, never from effects.
- Readout row recomputed at the scrub position from prefix sums: serves k/n,
  fastest, average 1st, average 2nd — a delivery with no shown serves reads
  "—".
- Hover/focus: invisible generous hit rects (full band width; the top unit of
  a stack owns the empty column above it) show an aria-hidden tooltip with
  serve #, speed, delivery and the point label, mirrored by a sr-only status
  region. The shown units form one roving-tabindex listbox: one tab stop,
  ArrowLeft/ArrowRight/Home/End walk the serves in match order, each option
  speaks a full sentence, focus-visible draws a ring-token frame.

Rendering & styling
- Colour from tokens only: first serves var(--chart-1), second serves
  var(--chart-2) — needle, histogram unit, readout chip and legend chip all
  share the delivery's token, so the two panels read as one linked view. The
  fastest marker and hub are fill-foreground/stroke-foreground; ticks and
  gridlines stroke-border / stroke-muted-foreground; all text in text tokens.
  Never #hex / rgb() / oklch().
- Dial: ~240° sweep (210° to -30°), minor and labeled major ticks, needle
  rotated via CSS transform around the hub, big tabular-nums readout + a
  bordered serve-number chip parked in the bottom wedge the needle can never
  enter. The dial svg is aria-hidden — its numbers live in the readout, the
  scrubber valuetext and the table.
- Histogram width from a ResizeObserver (disconnected on unmount), viewBox
  rebuilt from the measured width; unit height capped so short sequences
  still read as ticks. Panel rounded-xl border bg-card p-4, cn() merges
  className, root carries data-status. A sr-only table lists every serve.

Customization levers
- Tempo: BEAT_MS is the one knob for playback feel; GEARS can become
  [1, 2, 4, 8] and the radiogroup follows. NEEDLE_TAU stiffens or relaxes
  the kick.
- Grain: bandWidth rereads the same payload at another resolution;
  DEFAULT_BAND_WIDTH and MAX_BANDS live in one place each.
- Geometry: DIAL_START/DIAL_END reshape the sweep (a 180° semi works),
  DIAL_STEP changes the labeled tick stride, PLOT_H and MAX_SLOT_H resize
  the histogram.
- Ink: swap var(--chart-1/2) for other chart tokens; the emphasis ring's
  stroke is var(--primary); the fastest marker can take a chart token if a
  neutral tick is too quiet.
- Stats: the readout row is a small array — add "last 5 avg" or drop the
  averages without touching the layout.
- Domain: nothing but the labels is tennis — the same replay reads any
  ordered two-class speed stream (pitch speeds by pitch type, shot power,
  interval sprints).

Concepts

  • The frame is fixed, the story moves — band edges, dial ceiling and the tallest stack come from the whole sequence before playback starts, so scrubbing never re-scales an axis. What animates is only what happened: serves arriving.
  • Parked at the end, never autoplaying — the resting card is the finished summary (full histogram, needle on the last serve), so a reader who never presses Play still gets the answer; Play is an explicit "show me how it got here" that rewinds first.
  • One index drives two linked panels — dial and histogram share nothing but the scrub position and the delivery's colour token: the needle wears the same ink as the tick it just placed, which is what makes them read as one instrument rather than two charts in a row.
  • A chasing needle, not a keyframed one — the needle eases toward the current serve with a time-constant tween driven by rAF deltas, so a serve landing mid-flight retargets the motion instead of restarting it, and 4× playback degrades into a flick rather than a blur.
  • Records are events, not decorations — the fastest-so-far tick climbs the ring during playback and each new record is spoken once through a throttled polite live region, so the chart's most dramatic moment reaches screen-reader users when it happens, not after.
  • Reduced motion keeps the function — under prefers-reduced-motion the needle sets instantly, ticks appear without tween and the emphasis ring stops pulsing, but play, pause, scrubbing and every announcement still work; motion is a rendering choice, never a requirement.

On This Page