Match Workload
A four-state card for the physical bill of a match — distance and direction changes per set as paired bars on their own scales, a cumulative distance lane, and a tabbed per-set panel with work-to-rest and the average-to-peak speed range.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-match-workload.jsonPrompt
Build a React + TypeScript + Tailwind "ChartMatchWorkload" card — what one
match cost the legs, period by period — drawn as hand-rolled SVG (no chart
library), with zod for the contract.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
player: { name; context?; sport?: "tennis"|"badminton"|"pickleball";
periodLabel? };
sets: { index: int > 0; label; metres >= 0; directionChanges: int >= 0;
avgSpeedKph >= 0; peakSpeedKph >= 0;
workSeconds >= 0; restSeconds >= 0 }[] }
- Props = z.infer of that schema plus title?, defaultSetIndex?,
showCumulative?, onRetry?, emptyState?, className. No hand-written parallel
interface. forwardRef + spread the rest onto the root.
- The eight fields are raw sums, never rates: a rate hides how long the period
lasted. Every rate the card prints — metres per minute, work-to-rest, metres
per direction change — is derived from them.
- Two refinements the schema enforces, because they are unit errors rather than
taste: peakSpeedKph cannot sit below avgSpeedKph, and avgSpeedKph (the mean
while moving) cannot sit below metres / workSeconds (the mean over the whole
work clock, standing included).
- Set indexes must be unique. A repeated index is dropped, counted and
reported, never merged: two rows claiming to be set 2 are a contradiction,
and summing a contradiction invents a period nobody played.
Geometry — derive it, never guess pixels
- Court dimensions come from the rule books, in metres, and the sport picks the
row: tennis 23.77 x 8.23 singles with the service line 6.40 m from the net;
badminton 13.40 x 5.18 singles with the short service line at 1.98 m;
pickleball 13.41 x 6.10 with a 2.13 m non-volley zone.
- Distance is re-quoted in court lengths (metres / court length) and the
spacing of direction changes against the court width, so "3.70 km" becomes
"155.5 lengths of the tennis singles court" and "one turn every 3.3 m"
becomes "40% of the 8.23 m width". Those two divisions are the only place
the geometry enters, and both print the dimension they divided by.
- The plot frame is user units: one column per period at 84 units, a floor of
300 so two periods still get a readable frame, bars 104 tall, a 40-unit
cumulative lane under them. The frame widens with the period count instead of
squeezing columns.
Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton keeping
the four tiles, the paired bars, the cumulative lane and the tab row), empty
(faint frame + copy), error (message + a Try again button only when onRetry
exists), ready. A ready payload with nothing drawable falls back to the zero
state and says how many rows were dropped.
- Ready is four tiles, the plot, a legend, a verdict line, a period tablist and
one panel:
- Tiles: distance covered (+ court lengths), direction changes (+ one every
N m, as a share of the court width), work to rest (+ the share of the match
clock with the ball in play), peak movement speed (+ the work-time weighted
average while moving).
- Paired bars: distance and direction changes per period, side by side, each
series scaled to ITS OWN busiest period. They are different units, so the
pair must never share an axis; every bar prints its own number above itself
and the legend states both scale tops.
- Cumulative distance: one vertex at the END of each column, because a
running total is only true once the period it counts has finished. Same
chart token as the distance bars — same metres, counted twice.
- Verdict line: the heaviest period with its share, and whether peak speed
fell, held or rose from the first period to the last, in kph and per cent.
Both are computed from the payload, so the sentence cannot go stale.
- Interaction is one discrete choice: a roving-tabindex tablist of periods
(arrow keys wrap, Home/End, selection follows focus) driving one tabpanel
with that period's dense readout — distance and direction changes each as a
share of the MATCH (two mini-bars in the same dress must never be read
against two different wholes, so neither is normalised to the busiest
period), the spacing between turns, work-to-rest with a split bar, the
average-to-peak speed range on a scale that tops out at the match peak, and
the cumulative distance at the end of that period. The SVG itself is
role="img" and holds no listener, so there is no second, competing selection
path.
- Percentages that partition a whole are apportioned by LARGEST REMAINDER —
floor every share, then give the leftover points to the largest fractions —
so the period shares of distance and of direction changes each add to exactly
100, and so does work + rest inside every period. Rounding each slice on its
own prints 99 or 101 and reads as a data error.
- No effects, timers, observers or RAF: selection is resolved and clamped every
render, so a payload that loses a period cannot strand a tab that is no
longer on screen, and there is nothing to clean up on unmount.
Rendering & styling
- Semantic tokens only, one token per quantity: distance and its running total
var(--chart-1), direction changes var(--chart-2), work var(--chart-3), speed
var(--chart-4); frame lines stroke-border, the selected column fill-muted,
bar value labels fill-foreground so they keep contrast over any token in
either theme. Zero hex / rgb / oklch.
- The cumulative wash is a 0.25-opacity fill under a solid 2 px line, so the
lane still reads on a near-black card where a low-alpha fill alone would sink
into the surface.
- A mark is positioned by the same number that labels it: a bar's height, its
printed value and the panel's figure are one field; the share bar's width is
the apportioned per cent it prints.
- Accessibility: an sr-only status for loading, role="alert" for the error,
role="img" with the full reading as the SVG's name, APG tabs (aria-selected,
aria-controls, aria-labelledby, tabIndex 0 on the panel because it holds no
focusable child), focus-visible rings, and an sr-only table with every field
plus a match totals row. cn() merges className; motion-reduce stops the
skeleton pulse and the tab transitions.
Customization levers
- Lanes: showCumulative={false} drops the running-total lane and shortens the
frame; the bars and every readout are untouched.
- Density: COL_W (84) and BAR_H (104) set the plot's proportions, MIN_PLOT_W
the floor for short matches; drop the per-bar value labels for a sparser plot
once the tabpanel is doing the reading.
- Tiles: the four are a small array over the same totals — swap one for metres
per minute, turns per point, or the longest period, without touching the plot.
- Vocabulary: periodLabel renames a row ("set", "game", "period") and drives
the S1 / G1 tags under the bars; sport swaps the court the distances are
converted against.
- Palette: four chart tokens, one per quantity. Re-map distance to a brand
token and the bar, the cumulative line and the panel's share bar follow
together — they read the same constant.
- Scales: the two bar series are scaled independently on purpose. If your two
quantities really do share a unit, point both at one max and say so in the
legend.Concepts
- Two units, two scales, never one axis — metres and direction changes share a column but not a ruler: each series is normalised to its own busiest period and every bar prints its own number, so the card compares periods within a series and refuses the comparison it cannot honestly make.
- Largest-remainder apportionment — the period shares of distance, the period shares of direction changes, and the work/rest split inside each period are each floored together and handed their leftover points by largest fraction, so every one of them adds to exactly 100; per-slice rounding is how a card ships "99%". It is also what lets the panel's two mini-bars share one denominator — the match — while the plot above still scales each series to its own busiest period.
- Running totals land where the period ends — the cumulative vertex sits at the right edge of its column, not its centre, because the total is only true once that set has finished; it wears the distance token because it is the same metres counted again.
- Derived verdicts, not typed ones — the heaviest period and the first-to-last change in peak speed are computed from the payload every render, in kph and per cent, so the sentence the card prints about itself cannot drift from the data behind it.
- Court metres as the yardstick — distance is re-quoted in lengths of a real court (23.77 m tennis, 13.40 m badminton, 13.41 m pickleball) and turn spacing against its width, so an abstract "3.70 km" becomes a picture a coach already has.
- One discrete selection, one panel — periods are APG tabs with roving tabindex; the plot is a named image with no listeners, so there is no hover state racing the keyboard for what the readout means.
- Work is ball-in-play only — everything between rallies is rest, which is what makes the ratio comparable across sports: a fifth of a tennis clock, a quarter to a third of a badminton one.
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.
Partner Chemistry
A four-state doubles pairing grid — one roster on both axes, the lower triangle carrying each partnership's win rate over its match count, samples under the bar hatched out of every read, each player's own average on the diagonal to compare against, and a load strip whose widths are largest-remainder shares of the season.