Charts

Serve Sequence Replay

A replayable tennis serve chart — the receiver's half court in hand-rolled SVG, dots landing serve by serve under play/scrub/speed controls, with running in%, ace and fault counters recomputed at every position.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServeSequenceReplayCourt,
  ChartServeSequenceReplayData,
  ChartServeSequenceReplayServe,
} from "./chart-serve-sequence-replay.contract"

export interface ChartServeSequenceReplayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartServeSequenceReplayData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartServeSequenceReplay" chart — a
tennis serve sequence replayed dot by dot on a hand-rolled SVG court (no chart
library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    serves: { x: number; y: number; serve: 1 | 2; court: "deuce" | "ad";
              result: "in" | "ace" | "fault" }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, beatMs?, onRetry?, emptyState?
  and className. No hand-written parallel interface.
- serves[] is IN STRUCK ORDER — the array index is the timeline. Coordinates
  are metres: x across the court from the centre service line (negative =
  deuce/left box, ±4.115 at the singles sidelines, plus a drawn margin), y
  past the net (0 at the cord, 6.40 at the service line, plus a margin).
- Sanity rule: an "in" or "ace" serve must sit inside its named box. One that
  does not is pulled to the nearest point inside and the repair is reported in
  a visible line under the chart — never silently. Faults obey no box (net
  faults at y ≈ 0, long past the service line, wide of the sideline); they are
  only pulled onto the drawn margin's edge. Rows with unreadable coordinates
  or fields are dropped and counted out loud.

Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton
  mirroring the ready layout — counters, chips, court, control row, legend),
  empty (faint court + zero-data copy, also used when a ready payload has
  nothing drawable, then with the drop reason), error (message + "Try again"
  only when onRetry exists), ready.
- ONE timeline index — how many serves are placed — is the sole source of
  truth: the dots, the newest-serve emphasis, the outlined service box, the
  scrubber and the counters all read the same number. Store it unclamped and
  clamp on read so a shorter payload can never point past its own end.
- NO autoplay: the first ready render is parked at the end with every serve
  placed. Pressing Play from there rewinds to 0 and runs; Play/Pause is one
  toggle button with aria-pressed and a state-specific aria-label.
- Playback is a single requestAnimationFrame loop using timestamp deltas: one
  serve lands per beat (beatMs, default 600 ms, clamped 150–3000), speed
  1×/2×/4× divides the beat, accumulated time advances by whole beats so a
  dropped frame catches up instead of drifting. Cancel the frame on pause,
  scrub, end and unmount. Never setInterval; never Date.now()/Math.random()
  during render, so the first paint is deterministic.
- A labelled native range input scrubs the serve index at any time (it also
  pauses playback); aria-valuetext reads "k of n serves placed".
- Filter chips 1st / 2nd / all: filtering hides the other series' dots and
  re-bases the counters, but never recolours survivors — 1st serves are
  var(--chart-1) and 2nd serves var(--chart-2) always.
- The counter row is recomputed at the scrub position: serves placed k/n, in%
  vs fault% as a largest-remainder pair (they always sum to 100), the ace
  count and the fault count. Double faults are NOT derivable from this
  contract (it does not say which serves shared a point), so only faults are
  counted — do not fake a double-fault figure.
- Hover or keyboard focus selects a placed dot: the svg is role="listbox"
  with one role="option" hit-circle per placed dot (generous ~1 m radius),
  arrow keys / Home / End walking the struck order, aria-activedescendant
  announcing serve #, delivery, court, result and metres. Arm the keyboard
  cursor only on :focus-visible. The tooltip is an aria-hidden HTML layer
  anchored in percentages of the svg box, flipping above the dot near the
  net edge.
- A polite aria-live region speaks only at discrete moments — pause ("Paused
  at serve k of n") and the natural end of playback — never per frame.
- prefers-reduced-motion via matchMedia (useSyncExternalStore, listener
  cleaned up): serves still appear step by step, but the scale-in tween is
  dropped entirely, with motion-reduce: utilities as the CSS backstop.

Rendering & styling
- Geometry in metres, ITF: the receiver's half seen from behind the server
  with the net on the BOTTOM edge — serves fly up the card. Singles sidelines
  at ±4.115, service line 6.40 from the net, centre service line splitting
  deuce (left) from ad (right), one 1.37 m alley of drawn margin for wide and
  long faults with the doubles sideline on its outer edge. Court lines
  stroke-border, the net stroke-muted-foreground and heavier.
- Every dot is always mounted; revealing one is a transform move (scale 0 → 1
  with a ~200 ms transition), so the newest serve lands with a quick scale-in
  and scrubbing backwards folds dots away. The newest placed serve carries a
  temporary stroke-ring emphasis ring — temporary by construction, because
  the ring rides "the newest" and the next beat hands it on — and its named
  service box is outlined.
- Marks: filled dots with a ~2px var(--card) ring so stacked serves separate;
  aces slightly larger with an extra ring in their serve's colour; faults
  hollow, stroked var(--chart-5). Legend explains all four glyphs.
- Colour only from tokens: var(--chart-1/2/5) for the series, bg-card,
  text-muted-foreground, stroke-border, stroke-ring, bg-popover for the
  tooltip. No hex/rgb/oklch anywhere; text in text tokens with a var(--card)
  paintOrder halo where it crosses the drawing.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
  className; the root forwards ref, spreads remaining props and carries
  data-status. sr-only summary + a full data table repeat every serve.

Customization levers
- Tempo: beatMs is the one timing knob (speed divides it); swap the SPEEDS
  tuple for other multipliers. The scale-in duration is a single class.
- Filters: the chips key off serve 1|2 — extend the filter type to result
  ("aces only") by widening the predicate; the counters follow automatically.
- Marks: R / R_ACE / ACE_RING / EMPHASIS_RING are court-metre constants; grow
  them for dense season-long sequences or shrink for embed-size cards.
- Palette: the two series read var(--chart-1/2) and faults var(--chart-5) —
  re-point them at other chart tokens; the legend and chips inherit.
- Court: MARGIN widens the drawn out-band for faults; drop NET_PAD and the
  net label for a tighter crop; relabel courts for another racket sport —
  geometry constants are the only tennis-specific part.
- Counters: the row is one flex block recomputed from placedVisible — add a
  streak counter (consecutive first serves in) or a rolling in% window
  without touching the timeline.

Concepts

  • One timeline index — playback, the scrubber, the dots, the emphasis ring, the box outline and the counters all read a single "how many serves are placed" number; there is no second clock to drift out of sync, and scrubbing during playback is just writing that number.
  • Parked at the end, never autoplaying — the first ready paint shows the finished pattern (the chart is useful without ever being played), and Play means "rewind and watch it build"; motion only ever happens because the reader asked.
  • Replay, not aggregate — where chart-serve-placement sums serves into shaded zones and chart-serve-miss-map audits only the faults, this chart keeps every serve individual and ordered, so the when survives: the double-serve wobble mid-set reads as a cluster of hollow dots arriving together.
  • Counters as of serve k — in%, aces and faults are recomputed from the first k visible serves at every position, with in% vs fault% apportioned by largest remainder so the pair always sums to 100; scrubbing back literally rewinds the statistics.
  • Fixed colour identity — 1st serves are var(--chart-1), 2nd serves var(--chart-2), faults hollow var(--chart-5), everywhere and always; the filter hides dots but never recolours survivors, so a colour learned once stays learned.
  • Honest about what it cannot know — a serve called "in" that sits outside its box is pulled inside and the repair reported; unreadable rows are counted, not swallowed; and because the contract never says which serves shared a point, the card counts faults but refuses to invent a double-fault figure.

On This Page