Charts

Serve Speed Bands

A four-state tennis serve-speed distribution in hand-rolled SVG — first and second serves binned into fixed-width bands and drawn as two overlaid stepped outlines with translucent fills, an exact median line per series, and a crosshair hover that reads both counts for the band under the pointer.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServeSpeedBandsData,
  ChartServeSpeedBandsServe,
  ChartServeSpeedBandsUnit,
} from "./chart-serve-speed-bands.contract"

export interface ChartServeSpeedBandsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartServeSpeedBandsData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartServeSpeedBands" chart — a tennis
serve-speed distribution 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 }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, bandWidth?, height?, onRetry?,
  emptyState? and className. No hand-written parallel interface.
- The payload is RAW: one row per tracked serve, never pre-binned. The
  component owns the binning, so the feed never has to agree with the chart
  about band edges. The unit is echoed verbatim on the axis, the medians, the
  tooltip and the table — the chart never converts.

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
  keeps the ready silhouette — headline, two-hump histogram, legend, footnote),
  empty (two faint stepped outlines + copy, reused when a ready payload has
  nothing usable), error (message + "Try again" only when onRetry exists),
  ready.
- Binning is a pure exported function: filter unreadable rows and count the
  drops, snap edges to whole multiples of the band width (default 10 km/h or
  5 mph, clamped 1–100), bin left-closed/right-open with the top edge closed
  so the fastest serve is never lost, and bin BOTH series against one shared
  set of edges so their steps line up. If the range would draw more than 48
  bands, widen the width by a whole multiple and say so in the footnote.
- Medians are computed from the raw sorted speeds, never from binned counts,
  and drawn at their exact x position.
- Crosshair hover: the full plot is tiled with invisible per-band hit columns
  (far wider than the 2px outlines). Hovering highlights the band and shows a
  tooltip with the band range and both series' counts; the tooltip flips below
  the outline when the band nearly fills the plot and clamps inside the card.
- Keyboard: the hit columns form one roving-tabindex listbox — one tab stop,
  ArrowLeft/ArrowRight/Home/End move band to band, each band speaks a full
  sentence, focus-visible draws a ring-token frame on the band. The tooltip is
  aria-hidden; a sr-only role="status" region mirrors the pointer readout.
- A series absent from the payload drops its outline, its median and its
  legend row, and the footnote says only one delivery was tracked.

Rendering & styling
- Colour from tokens only: first serves stroke var(--chart-1), second serves
  var(--chart-2) with a dashed outline — identity never rests on hue alone.
  Both fills are the same token at fill-opacity ~0.16 so the overlap region
  shows both tints. Gridlines stroke-border, all text in text tokens
  (fill-foreground / fill-muted-foreground), never in a series colour.
- Median lines run under their own label row at the top of the plot, one row
  per series so two close medians never collide, each over a var(--card)
  under-stroke and each label haloed with paintOrder="stroke".
- y axis = serves per band, four intervals on a 1/2/2.5/5-style ladder of
  whole counts; x axis = band edges in the payload's unit, labelled on a
  stride wide enough that labels never touch.
- Width from a ResizeObserver (disconnected on unmount), viewBox rebuilt from
  the measured width. Panel rounded-xl border bg-card p-4, numbers
  tabular-nums, cn() merges className, the root spreads remaining props and
  carries data-status. Skeleton pulses honour prefers-reduced-motion via
  motion-reduce:animate-none. A sr-only table repeats every band's counts.

Customization levers
- Grain: bandWidth is the one knob that changes the story — 5 km/h shows
  serve-by-serve texture, 15 km/h shows only the two humps. The per-unit
  defaults live in one Record and are safe to retune.
- Size: height clamps 120–320; MAX_BANDS caps the comb before widening kicks in.
- Ink: swap var(--chart-1/2) for other chart tokens; FILL_OPACITY raises or
  lowers the wash; the dash pattern on the second series is a named constant.
- Medians: the marker row also takes p90 or a tournament-average reference —
  add entries to the medianMarks array and the label rows stack automatically.
- Series: the same two-outline machinery reads as "vs opponent" by feeding the
  opponent's serves as serve: 2 and relabelling the legend.
- Domain: nothing but the labels is tennis — the same contract charts any
  two-class speed sample (pitch speeds, shot power, sprint peaks).

Concepts

  • The component bins, the feed stays raw — radar guns disagree about band edges, so the contract carries per-serve speeds and the chart owns the grain. Changing bandWidth re-reads the same payload at a different resolution instead of asking the backend for a new shape.
  • One shared set of edges — both series are binned against the same round-multiple edges, so "180–190" is the same interval on both staircases. Binning each series from its own minimum would draw steps that never line up and make the overlap unreadable.
  • Stepped outlines, not bars — two full-opacity bar sets would occlude each other; two outlines with a thin shared wash keep both distributions legible exactly where they overlap, which is the region the chart exists to show.
  • Identity beyond hue — the second serve's outline is dashed and its legend chip repeats the dash, so the two series survive greyscale and colour-blindness; text never wears a series colour.
  • Medians are exact, bands are display — each median line sits at the median of the raw sorted speeds, not at the centre of its busiest band, so the number printed above the line is the number a physio or coach would compute from the CSV.
  • Every pointer readout has a keyboard twin — the band hit columns are one roving-tabindex listbox: arrows walk the axis, each band announces range and both counts, and the visual tooltip stays aria-hidden while a status region speaks for it.

On This Page