Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  MATCH_WORKLOAD_COURT_M,
  type ChartMatchWorkloadData,
  type ChartMatchWorkloadSet,
} from "./chart-match-workload.contract"

export interface ChartMatchWorkloadProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartMatchWorkloadData {

Installation

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

Prompt

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.

On This Page