Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartMatchStatsMirrorData,
  ChartMatchStatsMirrorStat,
} from "./chart-match-stats-mirror.contract"

export interface ChartMatchStatsMirrorProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartMatchStatsMirrorData {
  /** Card heading — what the panel is. Whose match it is comes from `players`. */

Installation

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

Prompt

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.

On This Page