Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartRallyReplayData,
  ChartRallyReplayHitter,
  ChartRallyReplayOutcome,
  ChartRallyReplayPoint,
  ChartRallyReplayShot,
  ChartRallyReplayStroke,
} from "./chart-rally-replay.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-replay.json

Prompt

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 is t % 1 through 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-motion the 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.

On This Page