Match Stats Mirror
A four-state broadcast-style head-to-head panel — mirrored bars from a centre label column, count rows split the pair's total while percent rows keep an honest 0–100 half-scale, the higher side at full fill and the lower faded, hairlines for zeros.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-match-stats-mirror.jsonPrompt
Build a React + TypeScript + Tailwind "ChartMatchStatsMirror" card — a
broadcast-style head-to-head match stats panel with mirrored bars around a
centre label column, 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: { name }, b: { name } };
context?: string;
stats: { id, label, a >= 0, b >= 0, format: "count" | "percent" }[] }
with a superRefine that count values are whole numbers and percent values
never exceed 100, plus unique stat ids. Component props = z.infer of that
schema plus title?, rowHeight? (20-44, default 28), onRetry?, emptyState?
and className. No parallel hand-written interface.
- Semantics, stated in the schema's JSDoc: format decides the row's scale —
counts are raw tallies (aces, winners), percents are already rates 0-100
(1st serve %). Player a always draws LEFT, player b always RIGHT.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton with the ready silhouette (mirrored rows around a
centre lane), aria-hidden, plus one sr-only role=status.
- empty: three mirrored muted lanes and copy explaining what will appear.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: legend header, mirrored stat rows, footnote.
- A ready payload with no stats, or where every value is zero for both
players, falls back to the zero state and says which of the two happened.
- Panel maths live in one exported pure function returning rows (values,
half-widths, the leader or null, printed count shares, zero flag) and a
repair count.
- Two half-scales, chosen per row by format:
- count: each bar is that side's share of (a + b) — 11 aces against 4 is
73% of one half against 27% of the other. The PRINTED shares come out of
one rounding (b gets what a leaves), the two-way case of
largest-remainder apportionment, so they always sum to exactly 100.
- percent: each bar is the value against a fixed 0-100 half — splitting a
68 vs 61 pair like a count would print 53-47 and shrink a 7-point gap.
- The higher side draws at full fill; the lower side keeps ITS OWN token at
reduced fill-opacity (0.4) — hue says whose bar it is, opacity only says
"smaller of the pair". A level pair fades no one.
- A zero value draws a hairline across its half; values that arrive
negative, fractional counts, percents over 100 or unreadable are repaired
and the repair is REPORTED, never swallowed.
- Interaction: rows form a roving-tabindex list — one tab stop, arrows/Home/
End between rows — hovering or focusing a row highlights the pair and
shows a centred floating tooltip (both values, count shares, the gap and
who is higher). The hit target is the whole row, far wider than the 8px
bars. Pointer-leave hands the tooltip back to the focused row.
Rendering & styling
- Five lanes per row: A's value, A's half, the centre label, B's half, B's
value. Bars are 8px, anchored at the CENTRE edge and rounded only on their
outer data end; value lanes are fixed-width (widest string) so every row
aligns; the label lane is a clamped ch width, centred, wrapping.
- Player tokens: a = var(--chart-1), b = var(--chart-2), the same constants
the legend chips and the tooltip chips read, so side and colour can never
disagree.
- Values print in text tokens, tabular-nums — the higher side font-medium
foreground, the lower muted; text never wears a series colour. Percent
values keep up to one decimal.
- The panel stays NEUTRAL: full fill marks the higher number, and the
footnote says explicitly that higher is not always better (double faults
count up too) — no green/red valence anywhere.
- Tooltip: fixed-width bg-popover card, centred over the row (the row spans
the card, so it cannot escape), aria-hidden because the row's aria-label
already says the same sentence.
- Accessibility: the list is role=group named by a full summary; each row is
role=img with a one-sentence label; an sr-only table repeats every number;
focus-visible ring on rows; hover transitions carry
motion-reduce:transition-none and skeleton pulses motion-reduce:animate-none.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
text-destructive, bg-popover, var(--chart-1), var(--chart-2). cn() merges
className; remaining props spread on the root.
Customization levers
- Stat vocabulary and order are the feed's: reorder into serve/rally/return
blocks, or swap tennis stats for any two-entity comparison (two products,
two branches) — the count/percent split carries over unchanged.
- Scales: keep the two-scale rule if you restyle; forcing percents through
the share split is the one change that silently lies. A max-count scale
(longest bar = biggest count in the panel) is a legitimate alternative for
all-count panels.
- Emphasis: TRAIL_OPACITY (0.4) sets how hard the lower side fades; 1.0
turns the panel into a pure mirrored bar chart with no leader emphasis.
- Density: rowHeight (20-44) sets the card's height; drop the tooltip's
share line for a terser read, or the legend when the surrounding page
already names the players.
- Palette: remap var(--chart-1)/var(--chart-2) to any two chart tokens; keep
chip, bar and tooltip reading from the same constant per player.Concepts
- Mirror as comparison — both bars grow from the same centre line in opposite directions, so "who has more of this" is a length comparison the eye does natively, row after row, without reading a single number. Side is bound to player and token in one constant, so the geometry can never contradict the legend.
- Two half-scales, honestly split — count rows state a balance, so they split the pair's total; percent rows state a rate, so they stand on a fixed 0–100 half. Forcing 68% vs 61% through the count split would draw 53–47 and quietly halve the gap; the footnote states the rule instead of hoping nobody notices.
- Largest-remainder shares — the printed count shares come from one rounding (the second side gets whatever the first leaves), so an 11–4 row prints 73% and 27%, never 73% and 26%. Two individually-true roundings that sum to 99 read as a broken total.
- Leader by opacity, not by virtue — the higher number gets full fill, the lower the same token faded, and that is all it means: on the double-fault row the "leading" bar is the worse performance. The panel never colours judgement, because half its rows would need the judgement inverted.
- Hairline zero rows — a 0–0 row keeps a hairline across each half instead of vanishing: "2nd serve aces: none for either player" is a measured fact, and an empty row would read as missing data.
- Row-level tooltip with a wide hit target — the whole row is the hover and focus target, many times taller than the 8px bars; the tooltip and the row's aria-label carry the same sentence, so pointer and screen-reader users get one story.
Pressure Points
A four-state pressure-situation profile — one horizontal lollipop per situation stemming from the player's overall win-rate baseline, above in chart-2 and below in chart-5, "62% (n=34)" labels, and hollow dots for samples under a contract-supplied floor.
Serve Speed Bands
A four-state tennis serve-speed distribution in hand-rolled SVG — first and second serves binned into fixed-width bands and drawn as two overlaid stepped outlines with translucent fills, an exact median line per series, and a crosshair hover that reads both counts for the band under the pointer.