Charts

Movement Trail

A four-state, scrubbable replay of one tennis player's movement — their own half court in hand-rolled SVG, the path fading with age and thickening with speed, a play/scrub/speed transport driven by a single timeline position, and a readout of distance and pace at any moment.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartMovementTrailData,
  ChartMovementTrailSample,
} from "./chart-movement-trail.contract"

export interface ChartMovementTrailProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartMovementTrailData {
  /** Card heading — what the chart is. Whose run it is comes from `player`. */

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartMovementTrail" chart — a playable
replay of one tennis player's movement in hand-rolled SVG (no chart library),
with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    player: string;
    samples: { t: number; x: number; y: number }[] }.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
  className; forwardRef, remaining props spread on the root, data-status on it.
- Coordinates are continuous metres, never pixels or frame indices: x is
  signed distance from the centre line (negative = the player's left, facing
  the net, sidelines at ±4.115), y is distance from the net into their own
  half (6.40 = service line, 11.885 = baseline, 14.385 = end of the drawn
  run-off). t is seconds on the tracker's clock, strictly ascending — the
  schema refines that, and the component repairs a violation rather than
  trusting it.

Geometry (derive it, do not guess pixels)
- ITF singles half court: 11.885 m deep from net to baseline, 8.23 m wide,
  service line at 6.40 m, centre service line and the 0.10 m centre mark. A
  2.5 m run-off strip is drawn behind the baseline as part of the playable
  frame — players stand there — with a lighter muted fill and a dashed edge,
  because its boundary is a camera crop, not court paint. The svg viewBox IS
  the court in metres, so no scale function stands between a sample and its
  mark.

Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
  mirrors the ready silhouette — readout row, court with a ghost path,
  transport, sparkline), empty (faint court + one explanatory line, reused
  when a ready payload has nothing usable, with the drop reason), error
  (message + a "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function: clamp a coordinate off the drawn frame
  onto its edge, drop a sample with a non-numeric field, drop a timestamp
  that fails to advance (a zero or negative time step is not a speed), count
  each repair by reason and report the counts in a visible line.
- Playback: a single timeline position in seconds held in state is the only
  source of truth — the trail, the dot, the readout, the scrubber and the
  sparkline cursor are all pure functions of it. One requestAnimationFrame
  loop advances it by the frame's timestamp delta × speed (never
  setInterval), cancelled on pause and on unmount. No autoplay: the initial
  ready render parks the timeline at the end so the complete trail shows, and
  pressing play rewinds to the start. No Date.now or Math.random anywhere in
  the initial render path.
- Transport row: a play/pause toggle (aria-pressed + aria-label), a labelled
  <input type="range"> scrubber that seeks by time — including while playing —
  and a 1× / 2× / 4× speed radiogroup.
- Readout recomputed at the scrub position, from raw metres: elapsed s,
  distance covered (summed segment lengths), average speed, current speed
  (the segment the scrub sits inside). A small speed-over-time sparkline sits
  under the court with the played portion inked in and a cursor at the scrub.
- Waypoints: every Nth sample (capped near a dozen) gets a generous invisible
  hit circle in a roving tab order — arrows move between revealed waypoints,
  Home/End jump — and hover or focus raises a tooltip with t, position and
  speed. The current-position dot does the same for "now". Waypoints ahead of
  the scrubbed time stay hidden until playback reveals them.
- prefers-reduced-motion (matchMedia via useSyncExternalStore, listener
  removed on unmount): the dot steps sample-to-sample with zero tweening —
  the clock still runs, the rendering refuses to interpolate — and every CSS
  transition carries motion-reduce:transition-none.
- An aria-live region speaks only on pause and on reaching the end (position,
  distance, average) — never per frame.

Rendering & styling
- Tokens only: the trail and dot in var(--chart-1) — segment strokeOpacity
  fades with age (recent ≈ 0.95 down to ≈ 0.16, normalised over the clip) and
  strokeWidth tracks that segment's own speed (subtle, about 2:1 sprint to
  shuffle); the sparkline in var(--chart-2); court lines stroke-muted-
  foreground, the net stroke-foreground, the run-off fill-muted. The dot wears
  a 2 px var(--card) ring via vector-effect: non-scaling-stroke. Never a hex,
  rgb() or oklch() anywhere.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
  className. The svg is a labelled group (role="group") — not role="img",
  which would silence the focusable waypoints inside it; the court markings
  and the trail are aria-hidden, each waypoint and the dot carry a full
  spoken sentence, and an sr-only table repeats every waypoint's time,
  position and speed. In-court labels use paintOrder="stroke" with a card
  halo so they survive the trail in either theme.

Customization levers
- Frame: RUN_OFF is the one knob for the strip behind the baseline (deep
  courts 3.5 m, a phone-tripod crop 1.5 m); the clamp range follows it.
- Trail: TRAIL_OPACITY_MIN/MAX and TRAIL_W_MIN/MAX trade drama for restraint;
  pin trailWidth to a constant for a uniform line, or swap the age fade for a
  fixed sliding window (fade over the last N seconds) on long clips.
- Transport: SPEEDS is a plain tuple — add 0.5× for coaching review; drop the
  sparkline block entirely for a tighter card, nothing else references it.
- Density: MAX_WAYPOINT_STOPS caps the tab stops; raise it for analyst use,
  lower it for a broadcast card.
- Palette: the trail/dot token and the sparkline token are the only two
  colour decisions — re-map them per player for a side-by-side pair of cards.
- Domain: the court is six constants and one markings component — swap them
  for padel or badminton and the ingest, playback and readout come along
  unchanged; the subject is whatever the tracker follows, so a ball-tracking
  feed works too (but see Rally Replay first).

Concepts

  • A single timeline position is the only source of truth — the trail, the dot, the readout, the scrubber and the sparkline cursor are all pure functions of one number in state. There is no second copy of "where we are" to drift: scrubbing while playing works because the animation loop simply advances from whatever the scrubber last wrote.
  • No autoplay, park at the end — the initial ready render shows the complete trail with the timeline at the last sample, so the card is a finished picture before it is ever a video; pressing play is the one thing that rewinds to zero. Motion is something the reader asks for, never something the card does to them.
  • The trail encodes two things without a legend — stroke opacity is age (recent ground strong, older ground fading toward, but never reaching, invisible) and stroke width is that segment's own speed, derived from consecutive raw samples. The parked-at-end render therefore already answers "which way did the run go, and where was it fast?" before any playback.
  • Reduced motion means stepping, not stopping — under prefers-reduced-motion the clock keeps running but the rendering refuses to interpolate: the dot lands on real samples only, with zero tweening, and every CSS transition is switched off. The replay stays fully usable with the motion taken out.
  • Repairs are counted out loud — a sample past the drawn frame is clamped onto its edge (the run continues at the boundary), and a timestamp that fails to advance is dropped rather than allowed to print an infinite speed; both counts are reported under the chart instead of quietly improving the numbers above it.
  • The live region is throttled by design — screen readers hear the position, distance and average on pause and on reaching the end, and nothing per frame: a replay that narrates sixty updates a second is a replay nobody can listen to.

On This Page