Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  SHOT_TEMPO_BAND_EDGES_MS,
  SHOT_TEMPO_BANDS,
  shotTempoBand,
  type ChartShotTempoData,
  type ChartShotTempoRally,
  type ChartShotTempoSide,
} from "./chart-shot-tempo.contract"

Installation

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

Prompt

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.

On This Page