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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-movement-trail.jsonPrompt
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.
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.
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.