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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-points-race.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPointsRace" card in hand-rolled
SVG with zod — a two-line points race that replays a pickleball game to 11,
rally by rally, with the scoring rules derived rather than trusted.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
teams: { a: { name: string }; b: { name: string } };
config?: { target?: int > 0 (default 11); winBy?: int > 0 (default 2);
scoring?: "sideout" | "rally" (default "sideout");
firstServer?: "a" | "b" (default "a") };
rallies: { winner: "a" | "b" }[];
context?: string }.
- ARRAY ORDER IS THE AXIS: the game's first rally first; nothing is sorted.
The data carries only who won each rally — points, side-outs, game points
and the game's end are all derived.
- `a` always takes var(--chart-1) and `b` var(--chart-2); never flipped.
- Props = z.infer of the schema plus title?, height (plot px, clamped
140-320, default 200), onRetry?, emptyState? and className; forwardRef to
the card div, cn() merges className, the root spreads remaining props and
carries data-status.
- Export the derivation beside the component so a test can print the same
numbers the picture is made of: derivePointsRace(rallies, config) walks the
serve through the rallies and returns per-rally rows (server, point or
side-out, running score, game-point flags, cumulative lead changes and
longest run) plus game totals, the winner, and trailing/dropped counts.
Behavior — the derivation is the product
- Side-out scoring: only the serving side scores. The serve starts at
firstServer; a rally won by the server is +1 and keeps the serve; a rally
lost by the server scores NOTHING and hands the serve over — a side-out.
Singles simplification on purpose: the doubles second-server rule is out of
scope. Rally scoring: every rally is +1 for its winner, who takes the serve.
- Game point is judged before each rally: a side that would reach target with
the winBy margin by taking this rally's point — under side-out scoring only
the server can be that side. The game ends at target with winBy; rallies
arriving after the end are never drawn and are reported in a visible line,
as are rallies with an unreadable winner (dropping one silently would shift
every score after it).
- Playback: ONE timeline index — rallies played. NO autoplay: ready parks at
the end with the final staircases visible, so first paint is deterministic;
Play rewinds to 0 and runs. The loop is requestAnimationFrame with real
frame deltas (never setInterval), cancelled on pause, speed change and
unmount; speeds 1× / 2× / 4× rescale the per-rally duration.
- Controls: a Play/Pause toggle (aria-pressed, aria-label switching between
"Play", "Pause" and "Play from the first rally"), a labelled native range
scrubber over the rally index whose aria-valuetext speaks "after rally k of
N, score", and a 1×/2×/4× speed radiogroup. Scrubbing pauses playback.
- prefers-reduced-motion via matchMedia (listener removed on unmount):
playback advances in whole-rally steps with no part-drawn tread — the race
still replays, it steps instead of gliding; decorative CSS transitions
carry motion-reduce:transition-none.
- aria-live, throttled: one polite status line announces a game point
reached, three side-outs in a row, pause (with the score) and the finish —
at most one message per ~1.4s window, never every rally.
- Hover a rally column for a tooltip (rally #, server, winner, point or
side-out, score after, game-point note); Tab focuses the plot and arrow
keys walk the rallies as a listbox via aria-activedescendant, Home/End
included. Hit targets are full-height, full-width columns; hovered wins
over the keyboard cursor, and both are clamped to the current rally count
so a shorter payload can never leave either pointing past the end.
Rendering & styling
- The plot is a fixed 1000 × 260 viewBox stretched with
preserveAspectRatio="none": x-units follow the card's width, y-units the
height prop. Every mark is an axis-aligned line or rect (shapes non-uniform
scaling cannot distort) with vectorEffect="non-scaling-stroke", so the 2px
race lines stay 2px at any card size. Nothing inside the svg is text — the
score, axis labels and tooltip are HTML at real px sizes.
- Two step-after staircases over the rally index (y = points, 0 to
max(target, overtime score)); the band between them is one axis-aligned
rect per rally interval, tinted with the CURRENT leader's token at low
fill-opacity, so a lead change reads as the band switching colour and a tie
leaves the gap unpainted. A dashed muted line marks the target.
- Side-out rallies get muted hairline ticks under the baseline (side-out
scoring only); game-point rallies get short team-coloured ticks in a strip
above the plot. Legend: a 2px line swatch per team, the side-out tick, the
game-point tick.
- Readouts follow the playhead: current score big (tabular-nums), rally
counter, lead changes, longest scoring run; a winner badge with the final
score appears when the playhead rests at the end.
- Colour from tokens only: var(--chart-1/2) lines and tints, stroke-ring for
the active column, border/muted inks for frame and axis, all text in text
tokens. Skeleton mirrors the ready layout; an sr-only table repeats every
rally with server, outcome and running score.
Customization levers
- Pace: BASE_RALLY_MS (per-rally duration at 1×) and the SPEEDS tuple set the
replay's feel; LIVE_GAP_MS throttles the spoken updates.
- Encoding: LEAD_FILL_OPACITY dials the leader tint from whisper to banner;
TEAM_TOKEN swaps sides onto other chart tokens; the game-point strip and
the side-out ticks are independent blocks — drop either for a quieter race.
- Scale: height 140-320 fits dashboards or hero cards; the target line, axis
labels and caption are one block each — remove them for a sparkline-sized
race.
- Readouts: the stat row is two spans — add cumulative side-outs, or keep
only the big score; the winner badge is one conditional.
- Domain: any race-to-N two-sided sport fits — volleyball or squash by
relabelling; scoring: "rally" already covers rally-point formats, and
target/winBy cover overtime rules.Concepts
- The derivation is the product — the data carries only who won each rally; the component walks the serve itself, so side-out scoring's whole rule (only the serving side scores, a lost serve scores nothing and passes the serve) is enforced by construction, and the staircases, tooltip, readouts and table all read one pass that can never disagree.
- A race, not a strip — two step-lines climb the same axis one point at a time, so "who is ahead, since when, by how much" is read from geometry: the band between the lines wears the current leader's colour, and a lead change is the band switching sides mid-chart.
- Parked at the end, replayed on demand — ready renders the finished race first (deterministic first paint, no autoplay, no timers); Play rewinds to 0 and re-runs it on a requestAnimationFrame loop driven by real frame deltas, cancelled on pause and unmount, with 1×/2×/4× rescaling the per-rally duration.
- A side-out is still an event — a scoreless change of serve leaves both staircases flat, so each one drops a muted tick on the axis: a flat stretch dotted with ticks is a serve battle, visibly different from a flat stretch where nothing was recorded.
- Game point is judged, not annotated — before every rally the derivation asks who would close the game by taking its point (under side-out scoring, only the server can); those rallies get team-coloured ticks in the top strip, so a blown game point followed by the converted one is legible at a glance.
- Motion respects the reader — under prefers-reduced-motion playback lands whole rallies instead of gliding through them, and the polite live region speaks only the moments worth speaking — game points, three-in-a-row side-outs, pause and finish — throttled to at most one message per window, never every rally.
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.
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.