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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-speed-bands.jsonPrompt
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
bandWidthre-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.
Match Stats Mirror
A four-state broadcast-style head-to-head panel — mirrored bars from a centre label column, count rows split the pair's total while percent rows keep an honest 0–100 half-scale, the higher side at full fill and the lower faded, hairlines for zeros.
Net Points
A four-state tennis net-approach composition card — one horizontal 100% stacked bar per way of coming in (won at net / passed / lobbed / error, largest-remainder shares), per-group n and success rate, a totals bar, segment tooltips, and a muted hairline for ways in that were never used.