Serve Sequence Replay
A replayable tennis serve chart — the receiver's half court in hand-rolled SVG, dots landing serve by serve under play/scrub/speed controls, with running in%, ace and fault counters recomputed at every position.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-sequence-replay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartServeSequenceReplay" chart — a
tennis serve sequence replayed dot by dot on a hand-rolled SVG court (no chart
library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
serves: { x: number; y: number; serve: 1 | 2; court: "deuce" | "ad";
result: "in" | "ace" | "fault" }[];
meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, beatMs?, onRetry?, emptyState?
and className. No hand-written parallel interface.
- serves[] is IN STRUCK ORDER — the array index is the timeline. Coordinates
are metres: x across the court from the centre service line (negative =
deuce/left box, ±4.115 at the singles sidelines, plus a drawn margin), y
past the net (0 at the cord, 6.40 at the service line, plus a margin).
- Sanity rule: an "in" or "ace" serve must sit inside its named box. One that
does not is pulled to the nearest point inside and the repair is reported in
a visible line under the chart — never silently. Faults obey no box (net
faults at y ≈ 0, long past the service line, wide of the sideline); they are
only pulled onto the drawn margin's edge. Rows with unreadable coordinates
or fields are dropped and counted out loud.
Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton
mirroring the ready layout — counters, chips, court, control row, legend),
empty (faint court + zero-data copy, also used when a ready payload has
nothing drawable, then with the drop reason), error (message + "Try again"
only when onRetry exists), ready.
- ONE timeline index — how many serves are placed — is the sole source of
truth: the dots, the newest-serve emphasis, the outlined service box, the
scrubber and the counters all read the same number. Store it unclamped and
clamp on read so a shorter payload can never point past its own end.
- NO autoplay: the first ready render is parked at the end with every serve
placed. Pressing Play from there rewinds to 0 and runs; Play/Pause is one
toggle button with aria-pressed and a state-specific aria-label.
- Playback is a single requestAnimationFrame loop using timestamp deltas: one
serve lands per beat (beatMs, default 600 ms, clamped 150–3000), speed
1×/2×/4× divides the beat, accumulated time advances by whole beats so a
dropped frame catches up instead of drifting. Cancel the frame on pause,
scrub, end and unmount. Never setInterval; never Date.now()/Math.random()
during render, so the first paint is deterministic.
- A labelled native range input scrubs the serve index at any time (it also
pauses playback); aria-valuetext reads "k of n serves placed".
- Filter chips 1st / 2nd / all: filtering hides the other series' dots and
re-bases the counters, but never recolours survivors — 1st serves are
var(--chart-1) and 2nd serves var(--chart-2) always.
- The counter row is recomputed at the scrub position: serves placed k/n, in%
vs fault% as a largest-remainder pair (they always sum to 100), the ace
count and the fault count. Double faults are NOT derivable from this
contract (it does not say which serves shared a point), so only faults are
counted — do not fake a double-fault figure.
- Hover or keyboard focus selects a placed dot: the svg is role="listbox"
with one role="option" hit-circle per placed dot (generous ~1 m radius),
arrow keys / Home / End walking the struck order, aria-activedescendant
announcing serve #, delivery, court, result and metres. Arm the keyboard
cursor only on :focus-visible. The tooltip is an aria-hidden HTML layer
anchored in percentages of the svg box, flipping above the dot near the
net edge.
- A polite aria-live region speaks only at discrete moments — pause ("Paused
at serve k of n") and the natural end of playback — never per frame.
- prefers-reduced-motion via matchMedia (useSyncExternalStore, listener
cleaned up): serves still appear step by step, but the scale-in tween is
dropped entirely, with motion-reduce: utilities as the CSS backstop.
Rendering & styling
- Geometry in metres, ITF: the receiver's half seen from behind the server
with the net on the BOTTOM edge — serves fly up the card. Singles sidelines
at ±4.115, service line 6.40 from the net, centre service line splitting
deuce (left) from ad (right), one 1.37 m alley of drawn margin for wide and
long faults with the doubles sideline on its outer edge. Court lines
stroke-border, the net stroke-muted-foreground and heavier.
- Every dot is always mounted; revealing one is a transform move (scale 0 → 1
with a ~200 ms transition), so the newest serve lands with a quick scale-in
and scrubbing backwards folds dots away. The newest placed serve carries a
temporary stroke-ring emphasis ring — temporary by construction, because
the ring rides "the newest" and the next beat hands it on — and its named
service box is outlined.
- Marks: filled dots with a ~2px var(--card) ring so stacked serves separate;
aces slightly larger with an extra ring in their serve's colour; faults
hollow, stroked var(--chart-5). Legend explains all four glyphs.
- Colour only from tokens: var(--chart-1/2/5) for the series, bg-card,
text-muted-foreground, stroke-border, stroke-ring, bg-popover for the
tooltip. No hex/rgb/oklch anywhere; text in text tokens with a var(--card)
paintOrder halo where it crosses the drawing.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
className; the root forwards ref, spreads remaining props and carries
data-status. sr-only summary + a full data table repeat every serve.
Customization levers
- Tempo: beatMs is the one timing knob (speed divides it); swap the SPEEDS
tuple for other multipliers. The scale-in duration is a single class.
- Filters: the chips key off serve 1|2 — extend the filter type to result
("aces only") by widening the predicate; the counters follow automatically.
- Marks: R / R_ACE / ACE_RING / EMPHASIS_RING are court-metre constants; grow
them for dense season-long sequences or shrink for embed-size cards.
- Palette: the two series read var(--chart-1/2) and faults var(--chart-5) —
re-point them at other chart tokens; the legend and chips inherit.
- Court: MARGIN widens the drawn out-band for faults; drop NET_PAD and the
net label for a tighter crop; relabel courts for another racket sport —
geometry constants are the only tennis-specific part.
- Counters: the row is one flex block recomputed from placedVisible — add a
streak counter (consecutive first serves in) or a rolling in% window
without touching the timeline.Concepts
- One timeline index — playback, the scrubber, the dots, the emphasis ring, the box outline and the counters all read a single "how many serves are placed" number; there is no second clock to drift out of sync, and scrubbing during playback is just writing that number.
- Parked at the end, never autoplaying — the first ready paint shows the finished pattern (the chart is useful without ever being played), and Play means "rewind and watch it build"; motion only ever happens because the reader asked.
- Replay, not aggregate — where
chart-serve-placementsums serves into shaded zones andchart-serve-miss-mapaudits only the faults, this chart keeps every serve individual and ordered, so the when survives: the double-serve wobble mid-set reads as a cluster of hollow dots arriving together. - Counters as of serve k — in%, aces and faults are recomputed from the first k visible serves at every position, with in% vs fault% apportioned by largest remainder so the pair always sums to 100; scrubbing back literally rewinds the statistics.
- Fixed colour identity — 1st serves are
var(--chart-1), 2nd servesvar(--chart-2), faults hollowvar(--chart-5), everywhere and always; the filter hides dots but never recolours survivors, so a colour learned once stays learned. - Honest about what it cannot know — a serve called "in" that sits outside its box is pulled inside and the repair reported; unreadable rows are counted, not swallowed; and because the contract never says which serves shared a point, the card counts faults but refuses to invent a double-fault figure.
Movement Trail
A four-state, scrubbable replay of one tennis player's movement — their own half court in hand-rolled SVG, the path fading with age and thickening with speed, a play/scrub/speed transport driven by a single timeline position, and a readout of distance and pace at any moment.
Score Ticker
A point-by-point tennis scoreboard replay — fed nothing but who won each rally, it derives games, deuce, tiebreaks and sets with real scoring rules, then plays the whole sequence back over a growing momentum strip with derived break, set and match points.