Shot Tempo
A four-state rally-rhythm strip — the time between consecutive contacts mirrored around a centre line, one player per side, with a rolling tempo band, flagged tempo breaks and a cross-rally distribution.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-shot-tempo.jsonPrompt
Build a React + TypeScript + Tailwind "ChartShotTempo" card — the rhythm of a
rally — with zod and hand-rolled SVG (no chart library).
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a: { name }, b: { name } };
rallies: { id, label?, shots: { player: "a" | "b", gapMs >= 0,
shotType? }[] }[];
context? }.
- gapMs is CONTACT TO CONTACT: milliseconds from the previous shot in this
rally to this one, so it carries the ball's flight, its bounce and the
striker's recovery in one number. The rally's first shot has no predecessor,
so its gapMs is ignored — it is the serve, and it ends no gap.
- Array order is the rally: nothing is sorted, because a shot carries no
timestamp of its own and a gap only means something against the shot in
front of it.
- Component props = z.infer of the schema plus title, rollingWindow (2-6,
default 3), breakRatio (1.1-3, default 1.5), defaultRallyId,
showDistribution, height, onRetry, emptyState and className.
- Export the band vocabulary from the contract file: four edge values in ms,
and DERIVE every printed range from them ("< 0.8 s", "0.8–1.1 s", "≥ 1.8 s"),
so the ramp, the legend, the distribution and the screen-reader table cannot
be re-cut half way. Each band also carries a one-line note about what a gap
that long is in play — that one is prose, not a number, and re-cutting the
edges means rewriting it.
Behavior
- Four first-class branches in one bg-card panel: loading (skeleton in the
ready layout's silhouette — two stat tiles, a chip row, a mirrored strip,
two distribution bars), empty (the bare centre line plus what a rally needs
before it has a rhythm), error (message + a retry button only when onRetry
exists), ready.
- Ready is a rally picker over one strip plus a cross-rally block:
- The picker is a tablist; each chip swaps the tabpanel below for one rally.
Roving tabindex, arrows/Home/End move and select, focus follows selection.
Rallies of fewer than two shots carry no gap and are counted under the
chart instead of offered as a chip that opens an empty panel.
- The strip: shot index along x, gap length along y, drawn away from a
centre line — player a above it, player b below, a mapping that never
flips. The serve is a tick on the line, because it ended no gap.
- The rolling tempo is the mean of the last `rollingWindow` gaps, drawn as a
band mirrored about the same centre line, so it narrows as the rally
accelerates and swells when somebody buys time. A bar that pokes out of the
band ran longer than the rhythm around it — that mean includes the bar
itself, so it is a far weaker statement than a break; only the bars that
also clear the break test carry a flag.
- A gap is a TEMPO BREAK when it moves off the mean of the gaps *before* it
(never including itself) by breakRatio in EITHER direction — at least
breakRatio times that mean, or at most its reciprocal — and by at least
350 ms either way. Both directions, because a rally breaks by accelerating
as surely as by slowing down and a one-sided test would announce that the
rhythm never broke over a point whose pace halved; the 350 ms floor,
because a ratio alone would flag a volley exchange for a tenth of a second
of nothing. A break needs two gaps of rhythm behind it; before that the
readout says so rather than inventing a baseline. The flag is drawn at the
bar's tip in headroom reserved above the tallest bar — pointing away from
the centre line when the gap swelled, back at the bar when it collapsed —
and it is credited to whoever struck the ball BEFORE the gap: that ball
either bought the next striker time or took it away.
- The strip is a listbox of gaps: hover or Tab-plus-arrows moves one cursor,
aria-activedescendant names the gap, and one sentence under the plot is
the pointer's visible echo (aria-hidden — the option already speaks it).
- The distribution bins every gap of every rally by the striker into the
five bands, one full-width stacked bar per player.
- Every claim the card prints is computed: the median, the break count, which
player opened the most slow-downs and which opened the most accelerations
(each with an explicit tie clause), and the share of balls each player takes
inside the second band edge. One framing throughout — a change is credited
to the racket it came off, never to the player who received it — so the
verdict and the per-break bullets can't hand the same event to both.
- Payload repair is reported, never silent: gaps that are not finite
non-negative numbers are dropped and counted, and their shot keeps its slot
on the axis so no later bar shifts off its shot number.
Rendering & styling
- Semantic tokens only. Player a is var(--chart-1), player b var(--chart-2) —
one formula feeding the bar fill, the legend chip, the serve tick and the
distribution ramp. Surfaces are bg-card / bg-muted / border; type is
text-foreground / text-muted-foreground; the error headline is destructive;
the focus ring is ring / outline-ring.
- The band ramp is the player's own token at climbing opacity over a bg-muted
track, so the palest block still has a surface under it instead of
dissolving into a near-black card.
- Percentages that partition a whole are apportioned by LARGEST REMAINDER —
and the stacked bar is laid out from those same integers, so its last block
lands flush with the end of the track and the five printed numbers total
exactly 100.
- The plot measures itself with a ResizeObserver (disconnected on unmount and
on node change) so text keeps a constant size; shot labels thin out on a
stride computed from the real slot width; the y scale is taken over ALL
rallies so switching chips never re-fits the axis under the reader.
- Numbers are tabular-nums; cn() merges className; the root spreads the rest
of its props. Skeleton pulses carry motion-reduce:animate-none and every
transition motion-reduce:transition-none.
- A screen-reader summary plus a five-row band table repeat every number; the
sr-only class sits on a wrapping div, never on the table itself.
Customization levers
- Rhythm sensitivity: rollingWindow (2 = follows a single ball, 6 = smooths a
whole exchange) and breakRatio (1.3 flags every change of pace, 2.0 only the
outright resets and the outright collapses — it gates both directions at
once). The 350 ms floor is a constant next to them.
- Band vocabulary: the four edge values in the contract — every printed range
is derived from them, so the legend, the ramp and the screen-reader table
re-cut together. The five band notes beside them are prose about tennis and
have to be rewritten by hand when the edges move. Badminton lives at roughly
half these numbers — a smash leaves the racket above 300 km/h but crosses
only 13.4 m — and padel and pickleball sit between the two.
- Density: showDistribution=false ships the strip alone; height (160-360)
trades bar resolution for card height; drop the two stat tiles for an
embedded panel.
- Palette: re-map SIDE_INK to a fixed token per player (team colours) and the
ramp follows, because the distribution reads its shade from the same map.
- Sport: nothing here is tennis-specific except the copy and the band edges —
the same card reads a table-tennis rally, a badminton rally or a squash
rally by swapping those two.Concepts
- Contact-to-contact gap — the only interval a stroke feed can measure without a body-tracking rig: ball flight plus bounce plus recovery, in one number. The serve has no predecessor, so it ends no gap and is drawn as a tick on the line rather than a bar of zero.
- Mirrored centre line — the striker of a shot picks its side of the line and its colour, so a bar can never sit above the line in the other player's token. Distance from the line is always seconds, in both directions, against one shared scale.
- Rolling tempo as a band, not a line — the running mean is drawn as a symmetric envelope, so the rally's rhythm reads as breathing: the band narrows through an acceleration and swells when someone buys time. A bar that pokes out of the band ran longer than the rhythm around it, nothing more — that mean includes the bar itself, so poking out is the weak, everyday version of the story and only a flagged bar is the strong one.
- A break is judged against the rhythm in front of it, both ways — the comparison window deliberately excludes the gap being tested, because a long gap that is already inflating the mean it is measured against can never look long. The test fires on a collapse as well as a stretch: a rally whose pace halves has broken its rhythm just as surely as one that stalls, and only a symmetric test can say so. Two gaps of rhythm are the minimum; before that the readout says so instead of inventing a baseline.
- Credit goes to the ball before the gap — a long gap is not slow play by the striker and a short one is not haste, it is what the previous ball did: hang in the air, or come back off a smash. The flag sits on the bar whose gap broke, pointing away from the line for time bought and back at the bar for time taken away, and every sentence in the card — verdict and bullet alike — names the racket the change came off rather than the player who received it.
- Largest-remainder shares — the five band percentages are apportioned together and the stacked bar is laid out from those very integers, so the printed numbers total exactly 100 and the last block lands flush with the end of the track.
- One scale across every rally — the y axis is fitted over the whole sample, not the selected rally, so switching chips compares like with like instead of re-drawing a 1.2 s exchange and a 2.4 s exchange as the same picture.
Tiebreak Ledger
A four-state tiebreak ledger — every tiebreak as a point-by-point strip with the serve above or below the line, mini-breaks hatched, plus a selectable scope driving the record, the hold rates and a largest-remainder split of where the points went.
Line Call Review
A challenge ledger that plots every review by the millimetres between ball and line, drawn against the paint and the ball at true scale, with each player's per-set allowance spent or still in hand.