Coverage Heat Replay
A four-state, scrubbable tennis coverage heatmap — presence time per half-metre cell accumulating over the match clock in hand-rolled SVG, with play/pause and speed controls, set markers on the rail, a colour scale fixed against the full match, and a busiest-zone readout recomputed at any moment.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-coverage-heat-replay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCoverageHeatReplay" chart — a
tennis coverage heatmap that accumulates over a scrubbable match clock, 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; context?: string;
samples: { t: number; x: number; y: number }[];
markers?: { t: number; label: string }[] }.
- Props = z.infer of the schema plus title?, maxDwellS? (0.5-10, default 3),
onRetry?, emptyState? and className; forwardRef, remaining props spread on
the root, data-status on it.
- t is seconds on one shared match clock, ascending — the array carries a
refine asserting the order so a shuffled feed fails loudly at the data
layer. The component is still defensive: it re-sorts arrival order, clamps
x to ±4.115 and y to 0..14.385, drops unreadable values, and prints every
repair count under the chart. A replay that silently reorders or discards
its own clock is lying about time.
- markers are named moments ("Set 2", "Rain delay"): ticks + labels on the
scrubber rail, announced when playback crosses them, and allowed to extend
the clock past the last sample.
Geometry (derive it, do not guess pixels)
- The player's own half from the ITF dimensions: 11.885 m deep (23.77 / 2),
8.23 m of singles width, service line 6.40 m from the net, centre service
line net → service line, centre mark at the baseline — plus a 2.5 m run-off
strip behind the baseline, because players live back there. The svg viewBox
IS the court in metres.
- Density grid: 0.5 m target cells, built span by span between the painted
lines (net → service line → baseline → back of run-off; the centre line an
edge by symmetry). Each span is cut into the whole number of cells nearest
the target, so the remainder every span leaves against 0.5 m is absorbed
across the span instead of piling up as a sliver at its edge: 16 × 29
cells, all within 3% of half-metre square, and no cell ever straddles a
line a reading is classified against. Court paint draws on top of the heat.
Behavior — the clock
- One timeline state, T in seconds, is the sole source of truth; the heat,
the readouts, the scrubber and the tooltips are all derived from it.
- Controls: a Play/Pause toggle (aria-pressed + a state-specific aria-label),
a labelled native range scrubber over the match clock (seek anytime, with
aria-valuetext speaking "m:ss of m:ss, in Set 2"), and a 1×/2×/4× speed
radiogroup.
- Playback is requestAnimationFrame with timestamp deltas — never
setInterval — cancelled on pause, on speed change and on unmount by the one
effect that owns it. The precise clock lives in a ref; state is quantised
to 0.1 s before setState so a 60 fps loop causes ~10 renders a second, not
60, across the card's ~900 SVG nodes.
- NO autoplay: ready parks at the end of the match with the full heat
visible, and Play from the end rewinds to 0 first. A payload that arrives
after mount (retry, refetch) re-parks at its own end.
- prefers-reduced-motion (useSyncExternalStore over matchMedia, listener
removed on unmount): playback stops sweeping and instead jumps
marker-to-marker — or eighth-of-match steps when there are no markers —
holding each step long enough to read; every CSS transition also carries
motion-reduce:transition-none.
- Throttled aria-live: one polite status region that only speaks at events —
play, pause (with the busiest zone at that moment), each marker crossed,
and the end. Nothing writes to it per frame.
Behavior — the heat
- Each sample holds its spot until the next sample, capped at maxDwellS (3 s
default) so a tracking dropout cannot smear one cell; capped gaps are
counted and reported. At clock T a sample with t ≤ T contributes
min(gap, T − t) — dwell is metered out as the clock passes through it, so
the tracked total never exceeds T and every cell grows monotonically.
- Shade is var(--chart-1) at five linear fill-opacity steps (~0.2 → 0.9) cut
against the busiest cell of the FULL match, never of the current clock.
Quantile or per-moment scales would re-rank cells mid-replay — a cell could
dim while its own seconds grow because a neighbour grew faster. Fixed
linear steps mean shade only ever climbs and the same shade means the same
seconds at every T; the legend prints the step boundaries in seconds and
the caption says the scale is fixed once.
- A cell with no presence is not drawn at all: "never stood there" is bare
court, not the palest shade of "stood there".
- Readouts recomputed at T: elapsed m:ss of total, the current phase (last
marker passed), and the busiest cell's zone name — ad/middle/deuce lane ×
inside the service line / no-man's land / behind the baseline, the same
lane-and-depth naming as the static coverage charts — with its share of
tracked time.
- Hover or arrow-key onto any cell (one tab stop, roving tabindex, arrows
clamp at the fences instead of wrapping, Home/End jump corner to corner) →
tooltip with the cell's zone, seconds and share at the current clock, the
same sentence echoed by a live region. An sr-only table gives every zone's
full-match time at the parked position.
- Four first-class branches: loading (court silhouette, pulsing blobs, a
ghost control row), empty (faint court + one explanatory line), error
(message + "Try again" only when onRetry exists), ready. A ready payload
with under two placeable samples falls back to the zero state and says why.
Rendering & styling
- Tokens only: one var(--chart-1) whose opacity carries the heat; court paint
stroke-muted-foreground (border is a 10%-alpha white in dark themes and the
baseline cannot be the faintest thing on the card), the net
stroke-foreground, the run-off frame dashed border — a dash says "the
chart's edge", a solid line says "paint". Text in text tokens, numbers
tabular-nums.
- Panel rounded-xl border bg-card p-4; cn() merges className; the play button
is a generous 36 px circle, the scrubber a 24 px-tall native range with
accent-primary, and every control has a focus-visible ring. Marker ticks
and labels are aria-hidden decoration; the markers are spoken through
aria-valuetext, the summary sentence and the crossing announcements.
- The keyboard focus frame is a solid ring-token rectangle, the pointer's a
dashed foreground one, so the two provenances never look alike.
Customization levers
- Dwell cap: maxDwellS is the one honesty knob — raise it for sparse GPS
feeds (one sample per rally), keep it low for 25 Hz optical tracking. The
cap is always printed, so the reader knows.
- Cell size: CELL (0.5 m) is the density resolution; the span-by-span edges
keep every court line on a cell boundary at any value. Coarser cells suit a
small dashboard tile, finer suit a full-width analysis view.
- Run-off: RUN_OFF (2.5 m) crops or extends the ground behind the baseline;
the depth-band naming and the sr table follow it automatically.
- Speeds: SPEEDS ([1, 2, 4]) is a plain array — add 0.5× for frame-by-frame
coaching review; the radiogroup renders whatever is in it.
- Reduced-motion cadence: REDUCED_HOLD_MS and the eighth-of-match fallback
are two constants; hold longer for presentation decks.
- Markers: feed rally-level markers ("Break point", "Rain delay") instead of
sets — ticks, labels, announcements and the reduced-motion step points all
come from the same array.
- Palette: the heat is one chart token — re-map var(--chart-1) per player to
overlay two replays side by side without touching the geometry.
- Domain: swap the five court constants for padel, pickleball or badminton —
the ingest, the accumulation and the grid construction are geometry
agnostic.Concepts
- One clock, everything derived — a single time state in seconds is the sole source of truth: the heat field, the elapsed readout, the busiest zone, the scrubber position and every tooltip figure are recomputed from it, so scrubbing, playing and keyboard walking can never disagree about what moment is on screen.
- Presence is metered, not granted — each sample holds its spot until the next one, capped so a tracking dropout cannot hand one cell a minute it never earned, and at clock T a sample contributes only the part of its dwell the clock has actually passed through. The tracked total never exceeds the clock, and every cell's value grows monotonically.
- The scale is cut once, against the whole match — five linear opacity steps to the busiest cell of the full match. A scale recut at every moment (or quantile steps) would re-rank cells mid-replay — a cell dimming while its own seconds grow, because a neighbour grew faster. Fixed steps mean shade only climbs, and the same shade means the same seconds at every T.
- Parked at the end, replayed on request — the card never autoplays: ready shows the full-match heat, the most informative single frame, and Play rewinds to zero to build it back. Under prefers-reduced-motion the rebuild advances marker to marker in readable jumps instead of sweeping.
- Markers are the narrative — set boundaries live on the scrubber as ticks and labels, name the current phase in the readout and the aria-valuetext, are announced as playback crosses them, and double as the reduced-motion step points; the same array drives all four.
- Repairs are printed, never silent — out-of-order timestamps are re-sorted, off-court sightings clamped onto the frame, over-long gaps capped, unreadable values dropped — and each count is reported under the chart, because a replay that quietly edits its own clock is fiction.
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.
Bracket Replay
A four-state playable knockout bracket — a 16-entrant draw revealed round by round with play/pause, a round scrubber, seed-aware upset chips, structural feed repair and a champion cell that fills at the end.