Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartCoverageHeatReplayData,
  ChartCoverageHeatReplayMarker,
  ChartCoverageHeatReplaySample,
} from "./chart-coverage-heat-replay.contract"

export interface ChartCoverageHeatReplayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartCoverageHeatReplayData {

Installation

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

Prompt

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.

On This Page