T Battle Replay
A four-state playable squash T-control replay — two tracked dots move on a metre-true WSF floor plan, a control ring on the T tints toward whoever holds it, controlled time accrues into a mirrored share bar, and rally-end ticks mark the scrubber with a running score.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-t-battle-replay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTBattleReplay" card — a squash
T-control battle replayed over a scrub clock on a metre-true floor plan, in
hand-rolled SVG (no chart library) with zod for the contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a, b };
samples: { t: seconds ascending, ax, ay, bx, by: court metres }[];
rallies?: { endT: seconds, winner: "a" | "b" }[];
controlRadius?: metres (default 1.2) }
Component props = z.infer of that schema plus title?, onRetry?, emptyState?
and className. No parallel interface.
- Coordinates are the WSF floor: x 0..6.4 across the court, y 0..9.75 from the
front wall. Player a is var(--chart-1) and b var(--chart-2) everywhere —
dots, trails, share bar, rally ticks; the chart never reorders them.
Behavior
- Four first-class branches in one bg-card panel: loading keeps the ready
silhouette (legend, court block, bar, transport); empty draws the court
glyph with the dashed T-zone ring, waiting; error offers "Try again" only
when onRetry is supplied; ready is the replay.
- Ingest is one exported pure function: coordinates off the floor are clamped
onto it, samples with an unreadable or non-ascending clock are dropped,
rally marks outside the tracked window are pinned to its edge — every
repair is counted and REPORTED under the chart, never swallowed.
- Control by exclusion, per sample interval, decided at the interval's start
sample: exactly one player inside controlRadius of the T (the junction of
the short line and the half-court line) owns that interval's duration; both
inside or neither inside is neutral time. Prefix sums over the intervals
make "controlled seconds up to any scrub time" a binary-search lookup, so
scrubbing anywhere is O(log n) per frame.
- One timeline value `time` (seconds) drives everything: interpolated dot
positions, ~2 s fading trails, the tint of the control ring, the mirrored
share bar, per-player held seconds, a neutral-share readout, and the
running rally score.
- Playback: NO autoplay — ready parks at the END of the clip (final shares
visible, dots on their last positions) for a deterministic first paint;
Play rewinds to 0. requestAnimationFrame with timestamp deltas (never
setInterval), multiplied by a 1× / 2× / 4× speed radiogroup; the frame is
cancelled on pause and unmount. The play button carries aria-pressed and an
aria-label; a labeled native range scrubber over the clock seeks at any
moment, playing or paused.
- prefers-reduced-motion (matchMedia, listener cleaned up on unmount):
positions step sample-to-sample instead of tweening, and every CSS
transition carries motion-reduce:transition-none. Playback still works —
the replay steps rather than glides.
- A throttled polite live region announces rally ends while playing (at most
one message per 1.5 s) and always announces pause and end-of-replay with
the shares and score at that moment.
- Hover or keyboard focus on the T-zone circle (the whole radius is the hit
target) shows who holds the T and both shares; each rally tick is a real
button with a generous 28px hit — hover/focus tells rally number, winner
and score after, click jumps the clock to that rally's end. A screen-reader
summary sentence and an sr-only rally table carry the exact figures.
Rendering & styling
- The court is drawn to WSF measure in a metre viewBox, front wall at the
top: 9.75 × 6.4 m floor, short line 5.44 m from the front wall, half-court
line from the short line to the back wall, two 1.6 m service boxes in the
short-line corners, the T emphasised as the one solid dot.
- The control ring is faint border ink, dashed, tinting toward the current
controller's token at low fill opacity; player dots take var(--chart-1) /
var(--chart-2) with 2px card-colored rings via vector-effect:
non-scaling-stroke; trails are ~10 line segments fading to transparent.
- The share bar mirrors chart-t-dominance: the track is B's fill, A's segment
is painted over it from the left, a 1px color-mix(var(--foreground) 55%,
transparent) centre rule marks 50%, and the printed pair goes through
largest-remainder rounding so the ends always sum to exactly 100.
- Semantic tokens only: bg-card, border, text-muted-foreground, bg-popover
for tooltips, var(--chart-1) / var(--chart-2), accent-color var(--primary)
on the range input. All text wears text tokens — color chips carry
identity, never colored text. cn() merges className, remaining props spread
on the root, decorative SVG groups are aria-hidden, every interactive
element has a focus-visible ring.
Customization levers
- controlRadius is the analysis: tighten to 0.8 m for a strict "on the T"
reading or widen for doubles; the ring, the legend label and the footnote
all quote it, so nothing else needs editing.
- Rally marks are optional — drop `rallies` and the scrubber, score line and
sr table quietly disappear while the control battle still replays.
- Playback grammar: SPEEDS ([1, 2, 4]) and the 0.1 s scrub step are one-line
constants; TRAIL_S / TRAIL_SEGMENTS set how much recent movement the trails
remember (0 segments removes them for a minimal dot view).
- Density: the court column is max-w-60 — widen it for a hero replay or drop
the share-bar block for a compact floor-only variant; the transport row
survives both.
- Palette: sides read var(--chart-1) / var(--chart-2); remap to any two chart
tokens but keep both solid — the centre rule and the ring tint have to
survive on top of each.
- Sport: the court constants are six numbers — swap them (and the T point)
for racquetball or a padel half to replay any zone-control battle from the
same contract.Concepts
- Control by exclusion — the T is owned only while exactly one player stands inside the radius; both inside is a contest and neither is open court, and both fall into a named neutral share instead of being silently split. The rule is decided per sample interval, so the accounting is exactly as good as the tracking cadence and no better.
- Prefix-sum accrual — controlled seconds are pre-summed at every sample, so any scrub time costs one binary search plus a partial interval. The replay is derived from the clock, never accumulated by the animation — scrubbing backwards, jumping to a tick and replaying all land on identical numbers.
- Parked at the end, never autoplaying — the first paint is the story's final frame: shares settled, dots on their last positions. Motion is the viewer's choice; Play rewinds to zero, and prefers-reduced-motion swaps the glide for honest sample-to-sample steps without losing the feature.
- Timestamp-delta playback — the clock advances by real elapsed milliseconds times the chosen speed, not by frame counts, so a throttled tab or a 120 Hz screen replays the same game in the same seconds; the frame is cancelled on pause and unmount.
- Largest-remainder pair — the two printed ends of the share bar partition controlled time, so they are rounded as a pair that sums to exactly 100; rounding each alone drifts to 99/101 and the bar stops being trusted.
- Territory vs scoreboard — rally ticks colored by winner sit under the control clock, so the moment control flips before the score follows (or a point falls against the T) is visible as a mismatch between the bar and the tick row — the same coaching gap chart-t-dominance shows per game, replayed here as it happened.
Serve Speed Replay
A four-state tennis serve-speed replay in hand-rolled SVG — a 240° dial whose needle kicks to each serve while a fixed-band histogram builds up one tick at a time, with play/pause, an index scrubber, speed gears, a persistent fastest-so-far ring marker and a scrub-time readout row.
Soccer Pass Network
A replayable soccer pass network in hand-rolled SVG — raw completed passes cut into match windows, a disc at each player's average position sized by involvements, lines weighted by passes exchanged, and a play / pause / step / scrub transport running on a real match clock.