Rally Elevation Replay
A four-state badminton rally replay in hand-rolled SVG — one rally played back shot by shot over a side-on 13.40 m court, the shuttle tweening along exact parabolic arcs under rAF playback with play/pause, a timeline scrubber and 1×/2×/4× speed, completed arcs persisting as muted strokes with walkable landing ticks, and physics guards that lift impossible arcs over the cord.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-elevation-replay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRallyElevationReplay" chart — a
badminton rally replayed shot by shot in a side-elevation, hand-rolled SVG
(no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a: string; b: string } — a defends the left half and plays
left→right, b the right half, so arcs alternate direction with the hitter;
shots: { by: "a" | "b";
stroke: "clear" | "drop" | "smash" | "drive" | "net";
fromDepth: 0..6.7 (metres from the net on the hitter's side);
contactHeight: metres above the floor at contact;
apexHeight: the flight's highest point in metres;
toDepth: metres past the net on the receiver's side }[]
— shots are IN RALLY ORDER; the order is the replay. }
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and the
native div props, spread on the root. forwardRef. No hand-written parallel
interface.
Court geometry (from the BWF book, not from pixels)
- The court is 13.40 m long, 6.70 m each side of the net; one shared scale
constant on both axes so the elevation is never stretched. The net is a
band at centre drawn 1.55 m tall — its height at the posts; the cord sags
to 1.524 m at the centre strap, which the footnote states because one
elevation cannot draw two cords.
- The floor line carries a depth tick every metre; the short service lines
(1.98 m) and the doubles long service lines (5.94 m) get labelled dotted
risers on BOTH sides. A height axis in metres stands on the left, its
ceiling following the tallest drawn apex but never below 4 m. Player names
label the two baselines.
Behavior
- Four first-class branches in one bg-card panel: loading (readout line, the
court frame with its net, a transport-row skeleton), empty (faint side-on
court + copy, reused when a ready payload has nothing drawable), error
(message + a "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function that keeps rally order and reports
everything it cannot honour: an unknown hitter or stroke, a non-finite
number or a non-positive contact height is unreadable (dropped, counted);
a landing depth at or below zero means the shuttle never crossed, so there
is no flight to replay (dropped by reason, counted); an apex below its own
contact height is raised to it (counted); an arc whose numbers imply a
flight through the mesh is lifted the minimum that clears the cord —
bracket by doubling, then bisect — and counted. Never repair silently.
- Each shot's arc is exact, not sampled: solve y = a(x − h)² + k through the
contact and the landing with apex height k in a canonical frame (contact at
negative x), emit one quadratic Bézier whose control point is the tangent
intersection, and mirror per shot by the hitter's direction. Because that
control point's x is the endpoints' midpoint, the Bézier parameter IS the
horizontal fraction — so De Casteljau at fraction f yields both the exact
partial path and the shuttle's position, flying at constant horizontal
speed.
Playback (the heart of the card)
- ONE timeline position is the sole source of truth: p ∈ [0, n], shot k owns
[k, k+1), continuous within the current arc. Everything — the partial arc,
the shuttle dot, the scrubber, the "shot k / n" readout, the landing ticks
— derives from p. No second clock, no per-arc state machines.
- NO autoplay: ready parks at the end (p = n — the whole rally drawn, the
outcome visible, deterministic first paint with no effects). Pressing Play
at the end rewinds to 0 and replays from the serve.
- rAF with timestamp deltas: the position advances by real elapsed time so a
dropped frame skips ahead rather than slowing the rally; the loop is owned
by one effect and cancelled on pause, speed change and unmount. Reaching
the end disengages the play button from inside the frame callback. No
setInterval anywhere.
- Controls: a Play/Pause button (aria-pressed + a state-specific aria-label),
a labelled range scrubber over the same p (arrow keys step one whole arc,
Home/End jump to serve and outcome, aria-valuetext speaks "shot k of n —
stroke by player"), and a 1×/2×/4× speed radiogroup.
- prefers-reduced-motion (matchMedia + listener cleanup): the same timeline
is snapped to whole arcs — each shot pops in complete after its beat,
nothing tweens — and every CSS transition carries motion-reduce: overrides.
- A visually hidden role="status" region announces ONLY on pause and on
reaching the end — throttled by design, never per frame.
- Rendering at position p: completed arcs persist as thin muted strokes
(dash retained); the current arc draws in its stroke-type token from
contact to the shuttle; each landed shot leaves a small landing tick in
its type's ink on the floor; the final landing gets a point-end ring once
the rally is done. A stroke chip + "shot k / n" readout name the current
shot above the plot.
- Hover or keyboard: the svg is one role="listbox" tab stop whose options
are the landed shots' ticks (a generous invisible hit circle makes a 2px
tick a real target); arrow keys walk them in rally order with
aria-activedescendant following, only :focus-visible arms the cursor, and
the active shot's arc re-inks while a tooltip at its tick reads shot #,
hitter, stroke, apex and landing depth. The tooltip is aria-hidden — each
option's aria-label already speaks the full sentence — and an sr-only
summary + table repeat every shot.
Rendering & styling
- Semantic tokens only. Identity is double-encoded and never re-assigned:
clear = var(--chart-1) solid, drop = var(--chart-2) dashed, smash =
var(--chart-3) dotted, drive = var(--chart-4) dash-dot, net shot =
var(--chart-5) fine-dot — the dash patterns carry the stroke on their own
for a greyscale or colour-blind reader, on the current arc, the legend
chips and the readout chip alike.
- Court paint: gridlines stroke-border, floor and ticks
stroke-muted-foreground, tape stroke-foreground/70, net fill-muted; every
label wears paintOrder="stroke" with a var(--card) halo so an arc can never
blot it out. The shuttle dot wears a var(--card) ring. The scrubber's
elapsed track paints var(--primary) via one CSS custom property.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
className, root spreads remaining props and carries data-status; the court
group is aria-hidden.
Customization levers
- SHOT_MS is the one beat: every arc takes the same time at 1×, so the rally
has a pulse — scale it, or the SPEEDS array, without touching the loop.
- The frame's ceiling follows the tallest drawn apex (never below MIN_Y_MAX);
raise the minimum for a card that mixes rallies of very different heights.
- CLEAR_MARGIN is how far above the cord a lifted arc must pass; NET_W is
the drawn width of the net band — a drawing device, since a net has no
depth.
- TYPE_INK / TYPE_DASH are the only home of the stroke identity — add a
sixth stroke (lift, push) by extending the enum and those two maps, and
ingest, legend, chip and table follow.
- Drop the landing ticks for a pure playback card, or park ready at 0
instead of n if the surface prefers a teaser to a spoiler — both are one
constant.
- Sport swap: the same skeleton replays any "projectile over a barrier"
exchange — volleyball digs and sets, tennis from the side — by renaming
the strokes and swapping the four geometry constants.Concepts
- One position, everything derived — playback, the scrubber, the readout, the partial arc, the shuttle dot and the walkable ticks all read a single timeline position p ∈ [0, n]; there is no second clock to drift, and pausing, scrubbing and speed changes are all writes to the same number.
- Parked at the end, never autoplaying — a chart that moves uninvited is a chart that can't be read; ready renders the whole rally with its outcome visible as the deterministic first paint, and Play rewinds to the serve, making motion always the reader's choice.
- The parabola is exact, and so is the tween — each arc is solved through contact, apex and landing as y = a(x − h)² + k and emitted as one quadratic Bézier whose control x is the endpoints' midpoint; that property makes the Bézier parameter the horizontal fraction, so De Casteljau at f gives both the partial path and the shuttle's position with no sampling and no arc-length table.
- Physics is a rendering guard — a shuttle that landed past the net crossed it, so an arc whose numbers imply a flight through the mesh is lifted the minimum that skims the cord, an apex below its own contact is raised to it, and a landing at or behind the net is dropped by reason — every repair counted and printed, never silently obeyed or hidden.
- Reduced motion changes the tween, not the story — under prefers-reduced-motion the same timeline snaps to whole arcs: each shot pops in complete after its beat, the replay still replays, and nothing glides; CSS transitions carry motion-reduce: overrides besides.
- The floor remembers the rally — every landed shot leaves a tick in its stroke's ink at its landing depth, each a listbox option with a generous hit target, so after the replay the exchange can be re-read shot by shot with the arrow keys, tooltip and sr-only table telling the same numbers.
Points Race
A four-state pickleball points-race replay in hand-rolled SVG — two staircase step-lines climb one point per rally under derived side-out or rally scoring, the gap tinted with the leader's colour, side-out and game-point ticks on the axis, and rAF playback with play/pause, a rally scrubber and 1×/2×/4× speeds that parks on the final score.
Coverage Heat Replay
A four-state, scrubbable tennis coverage heatmap — presence time per half-metre cell accumulating over the match clock in hand-rolled SVG, with play/pause and speed controls, set markers on the rail, a colour scale fixed against the full match, and a busiest-zone readout recomputed at any moment.