Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartRallyElevationReplayData,
  ChartRallyElevationReplayShot,
  ChartRallyElevationReplayStroke,
} from "./chart-rally-elevation-replay.contract"

export interface ChartRallyElevationReplayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartRallyElevationReplayData {

Installation

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

Prompt

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.

On This Page