T Dominance
A four-state squash T-control card — one mirrored bar per game split at each player's share of rally time on the T around a 50% centre rule, the score under every bar, a match row on top, and the games that defied the T called out.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-t-dominance.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTDominance" card — a squash
T-control report drawn as one mirrored bar per game around a 50% centre rule,
in hand-rolled CSS (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 };
games: { label, tShareA: 0..1, points: { a >= 0, b >= 0 } }[] }
Component props = z.infer of that schema plus title?, barHeight? (8-28,
default 14), onRetry?, emptyState? and className. No parallel interface.
- Semantics, stated in the schema's JSDoc: tShareA is player A's share of
TRACKED RALLY TIME holding the T; B's share is 1 - A by construction, so
only one number travels. Points stay separate on purpose — holding the T
and winning the game are different claims, and their gap is the story.
- Player a is always the left side of every bar and var(--chart-1); b mirrors
right in var(--chart-2). The chart never reorders them.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: an aria-hidden skeleton keeping the row silhouette (label line,
bar, score line), plus one sr-only role=status line.
- empty: a plan-view squash court glyph (WSF 9.75 x 6.4 m, short line
4.26 m off the back wall, two 1.6 m service boxes, half-court line) with
the T itself as the one emphasised dot, and copy naming what will appear.
- error: a destructive message and a "Try again" button only when onRetry
is supplied.
- ready: legend, match row, a separator, one row per game, an axis caption
and a footnote.
- Layout maths live in one exported pure function returning the rows (repaired
share, printed pair, points, winner), the match aggregate and the games that
defied the T (majority holder lost on points).
- The match share is the EQUAL-WEIGHT mean of the game shares — rally
durations never travel with the feed, so weighting games by anything would
be invented precision; say so in the footnote. Games and points pool the
ordinary way.
- The two printed ends of a bar partition one whole, so the pair goes through
largest-remainder rounding and sums to exactly 100 on every row.
- Out-of-range or non-finite shares and counts are repaired (clamped/floored)
and the repair is REPORTED under the chart, never swallowed.
- Hover or keyboard focus on a row shows a tooltip (both shares, the score,
who won); rows are tabIndex=0 with a full aria-label and a focus-visible
ring, and hover/focus are tracked separately.
Rendering & styling
- Per row: the track itself is painted var(--chart-2) (player B), player A's
var(--chart-1) segment is laid over it from the left at tShareA — the two
sides always meet at exactly the split with no seam arithmetic. The 50%
centre rule is a 1px color-mix(var(--foreground) 55%, transparent) hairline
drawn last, over both fills; identical row padding makes it run straight
down the whole card.
- Printed shares sit outside both ends of the track in a fixed 2.25rem column
(tabular-nums), so every bar starts and ends on the same x and the centre
rules align. The game label sits above the bar, the score line ("11-7" plus
the winner's name) below it, both indented to the bar's left edge.
- The match row is drawn first, slightly taller (bar + 4px), separated from
the games by a border-t list item.
- One-line axis caption under the rows: "share of rally time on the T -
centre rule marks 50%".
- Semantic tokens only: bg-card, border, text-muted-foreground, bg-popover for
the tooltip, var(--chart-1) / var(--chart-2), color-mix on var(--foreground)
for the centre rule. cn() merges className, remaining props spread on the
root, skeleton pulses carry motion-reduce:animate-none, decorative bar
internals are aria-hidden.
Customization levers
- Row vocabulary: rows are whatever the feed sends in order — swap games for
sets, for five-minute windows of one game, or for a season of matches with
one row per opponent.
- Aggregate rule: the match mean is equal-weight; if rally durations DO travel
with your feed, weight by them in the layout function and update the
footnote sentence — never silently.
- Density: barHeight (8-28) plus the gap-3 list spacing set the card height;
drop the per-row labels to sr-only for a compact scoreboard variant.
- Palette: sides read var(--chart-1) / var(--chart-2); remap to any two chart
tokens, but keep both solid — the centre rule has to survive on top of each.
- Upset call-out: the "held the T, lost the game" sentence keys off winner vs
share majority; tighten it to only fire past a threshold (e.g. share > 0.55)
for noisy tracking data.
- The scores under the bars come straight from points; hide them if the feed
has share data but no scores, and the bars still stand alone.Concepts
- Territory vs scoreboard — the bar says who owned mid-court, the score line under it says who took the game, and the card refuses to merge them into one number. The most coachable game is the one where they disagree, so those games are named in the footnote instead of left for the reader to spot.
- 50% centre rule — every bar is judged against the same vertical hairline, drawn over the fills in a foreground-derived ink because a border token would vanish on top of two solid colours. Identical row padding is what lets one rule run straight down the card without measuring anything.
- Largest-remainder pair — the two printed ends partition one whole, so they are rounded as a pair that sums to exactly 100; rounding each end alone drifts to 99/101 and the reader stops trusting the bar between them.
- Equal-weight match mean — the match row averages game shares without weights because rally durations don't travel with the feed; weighting by points would smuggle the scoreboard into a territory statistic. The footnote says so out loud.
- Sides never swap — player A is the left of every bar and the first chart token from contract to legend to tooltip, so colour and side both mean the same player all the way down; a chart that reorders by winner would repaint the same player two colours in one card.
- The T as a place, not a metaphor — the zero state draws the court in plan from the WSF measurements (9.75 × 6.4 m, short line 4.26 m off the back wall, 1.6 m service boxes) with the T junction as the one emphasised dot, so the card teaches what is being measured before any data lands.
Serve Spin Mix
A four-state table-tennis serve rose — five spin families at fixed semantic anchors, each wedge's area its share of serves, the win rate printed at every wedge tip and the total serve count in the hub.
Padel Wall Play
A four-state padel wall-play ledger — one bar per wall-interaction class (direct, back wall, side wall, double wall) sized by shot count on a shared scale, split into points won and lost, with usage shares, win rates and a bounce glyph per class.