Rally Replay
A playable top-down replay of one tennis rally in hand-rolled SVG — the ball tweens along each shot's curved path over a to-scale ITF singles court, landed shots persist as trails with walkable bounce dots, and play / scrub / speed controls drive a single timeline that starts parked at the finished point.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-replay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRallyReplay" chart — a playable
top-down replay of ONE tennis rally in hand-rolled SVG (no chart library, no
animation library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a: string; b: string };
shots: { by: "a" | "b";
from: { x: number; y: number };
to: { x: number; y: number };
stroke: "serve" | "forehand" | "backhand" | "volley" | "smash" | "lob";
outcome?: "winner" | "error" }[] }.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
className. No hand-written parallel interface.
- Coordinates are court metres from the court's centre: x across the court
(±4.115 at the singles sidelines), y along it (±11.885 at the baselines,
net at 0). Hitter `a` defends the negative-y half. A ~1.5 m out-margin past
every line is drawn and legal — an error has to be able to land out.
- `outcome` is meaningful on the final shot only: winner = the last ball
landed in untouched, error = the hitter of the last shot lost the point.
- Ingest repairs instead of trusting: non-finite coordinates drop their shot,
points beyond the margin are clamped onto its edge, a `from` on the wrong
side of the net for its hitter is drawn as given but flagged, an outcome on
a non-final shot is stripped — and every one of those counts is printed in a
visible line, because a replay that quietly redraws the rally is showing a
point that was never played.
Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
mirrors the ready silhouette — stat line, the court itself, chip row,
transport controls, legend — so the card keeps its height), empty (faint
court + copy, reused with a stated reason when a ready payload has nothing
drawable), error (message + "Try again" only when onRetry exists), ready.
- One timeline position in state is the ONLY source of truth for playback,
measured in shot units: floor(t) shots have landed, the ball is t % 1 of the
way through the next flight. Everything on the card — trails, dots, ball,
chip, counter, scrubber, badge — derives from it.
- NO autoplay. The first render parks the timeline at the end, so a static
preview, a screenshot or an SSR paint shows the complete rally with its
outcome badge. Pressing Play from there rewinds to 0 and replays.
- Playback is one requestAnimationFrame loop advancing the timeline by the
frame's timestamp delta × the selected speed (1× / 2× / 4×), walking shot by
shot because each flight has its own duration (≈55 ms per metre of travel,
paced by stroke — a smash arrives in about half the time of a rally ball, a
lob hangs for nearly twice as long, clamped 320–2400 ms). The loop is
cancelled on pause AND on unmount; never setInterval, and no Date.now or
Math.random anywhere in the first render path.
- Transport row, all keyboard operable with focus-visible rings: a play/pause
toggle button (aria-pressed + a state-specific aria-label), a labelled
native <input type="range"> scrubber with one step per shot and an
aria-valuetext that speaks "shot k of n" — seeking works mid-playback, the
loop simply continues from wherever the thumb lands — and a 1×/2×/4× speed
radiogroup.
- prefers-reduced-motion (via matchMedia, subscribed with cleanup —
useSyncExternalStore fits): playback still works, but the rendered position
is quantised to whole shots, so each flight pops in complete when its time
has elapsed — discrete steps, zero tweening. CSS transitions carry
motion-reduce: variants.
- A sr-only aria-live region announces on pause ("Paused at shot k of n") and
when playback reaches the end (the closing call) — never per frame. Derive
the end text and empty the region while running, so a replayed rally
re-announces.
- Landed bounce dots are a keyboard walk: the svg is role="listbox" with
aria-activedescendant, arrow keys / Home / End move a cursor over landed
shots in rally order, and only :focus-visible arms it so a stray click
doesn't pin the tooltip. Hover and cursor share one tooltip (shot #, hitter,
stroke, landing in metres) anchored in viewBox percentages with a generous
transparent hit circle around each dot.
Rendering & styling
- Court geometry in metres, derived from the ITF singles court and never from
pixel guesses: 23.77 m by 8.23 m, service lines 6.40 m either side of the
net, centre service line between them, 10 cm centre marks inward from each
baseline's midpoint, and the net drawn past the sidelines to the singles
sticks 0.914 m outside each. Drawn lengthways (court-y runs across the
card), so `a` is the left half and the ~2.3:1 aspect fits a card.
- Court paint strokes border tokens over a bg-muted wash; the net is the
stronger stroke-muted-foreground; the strip past the lines stays
card-coloured so "out" reads as off the ground.
- Each shot flies a gentle quadratic: control point at the segment's midpoint,
pushed perpendicular by a stroke-dependent fraction of its length (lob bows
most, smash least, capped so nothing exits the canvas) — `a`'s shots arc one
way, `b`'s replies the other, so overlapping trails stay tellable apart.
- The current flight wears its hitter's token — a is var(--chart-1), b is
var(--chart-2), everywhere — and is revealed by dash offset over
pathLength=1; completed flights persist as thin stroke-muted-foreground
trails with a bounce dot at each landing; the ball is a small
hitter-coloured circle riding B(t) on the quadratic.
- A stroke-kind chip (with a hitter-token dot) plus a "shot k / n" counter sit
between court and transport, in text tokens only.
- When the timeline reaches the end, an outcome badge lands near the final
bounce: winner tinted var(--chart-2), error var(--chart-5), via fill-opacity
on the token — the badge is aria-hidden decoration, the call is spoken in
the header, the live region and the table.
- Legend maps both players to their tokens with their court side. One sr-only
summary paragraph plus a table with every shot's hitter, stroke, contact,
landing and call. Colour only ever comes from semantic tokens (border,
muted, card, popover, primary for the scrubber accent, var(--chart-*)); no
hex, rgb() or oklch() anywhere; cn() merges className and the root spreads
remaining props with data-status.
Customization levers
- Tempo: MS_PER_METRE, the per-stroke pace map and the min/max clamp set how a
rally feels; SPEEDS ([1, 2, 4]) is just an array — swap in 0.5× for coaching
frame-by-frame study.
- Flight shape: STROKE_BEND per stroke and MAX_BEND flatten or exaggerate the
arcs; zero them all for straight-line laser paths.
- Court crop: OUT_MARGIN widens the legal landing strip for wilder errors;
drop it near zero for a tight broadcast crop.
- Tokens: hitters read HITTER_TOKEN and the badge OUTCOME_TOKEN — re-key them
to any chart tokens, e.g. team colours from the host app.
- Blocks: the stat header, chip row, legend and ITF footnote are independent
siblings — drop any of them for an embedded mini replay, or replace the
header with your own scoreline.
- Domain: relabel strokes and swap the court-lines group to re-court the same
replay engine for padel, pickleball or badminton — the metre constants are
the only tennis-specific thing in it.Concepts
- One timeline, everything derived — a single number in shot units is the only playback state:
floor(t)shots have landed, the ball ist % 1through the next flight. Trails, dots, chip, counter, scrubber thumb and badge all derive from it, so play, scrub and keyboard seeking can never disagree about what has happened. - Parked at the end, never autoplaying — the first render shows the finished point, outcome badge and all, so a screenshot, an SSR paint or a docs thumbnail is already the full story; motion is opt-in, and Play from the end means "rewind and watch". This is also what keeps the first frame deterministic: no clock is read until playback starts.
- Time is data, not frames — the rAF loop advances by timestamp delta × speed against per-shot durations derived from flight distance and stroke, so a dropped frame skips forward instead of slowing the rally down, and 2× is exactly twice 1×. The loop is cancelled on pause and on unmount alike.
- Reduced motion changes the tween, not the feature — under
prefers-reduced-motionthe same clock runs, but the rendered position quantises to whole shots: each flight appears complete when its time has passed. Playback, scrubbing, speed and announcements all keep working with zero tweening. - The ball, not the player — every path here is a ball flight from contact to bounce; a player's running path between shots is a different chart (chart-movement-trail). Keeping the two apart is what lets each stay honest about what its coordinates mean.
- Repairs are counted out loud — unreadable shots are dropped, out-of-margin points clamped onto the margin's edge, wrong-half contacts flagged, stray outcome calls stripped, and each count is printed under the chart. A replay that silently redraws the rally is showing a point that was never played.
Shuttle Trajectory
A four-state badminton flight-signature chart in hand-rolled SVG — the full 13.40 m court seen from the side with a to-scale net at centre, each stroke type aggregated into a median parabolic arc with a p25–p75 band, dash patterns as second identity, landing marks on the floor and physics guards that lift impossible arcs over the cord.
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.