Winners vs Errors
A four-state winners-and-errors ledger — one diverging bar per shot group with winners one way, unforced errors the other and forced errors extending the same bar, plus the winners-per-unforced ratio and the group that leaks most.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-winners-errors.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWinnersErrors" card — a tennis-style
winners / forced / unforced ledger drawn as one diverging bar per shot group, 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";
groups: { id, label, winners >= 0, forced >= 0, unforced >= 0 }[];
meta: { player, context?, pointsPlayed? } }
with a refine that group ids are unique. Component props = z.infer of that
schema plus title?, minSample? (0-200, default 8), barHeight? (10-32, default
18), onRetry?, emptyState? and className. No parallel hand-written interface.
- Semantics, stated in the schema's JSDoc: the three counts are POINT ENDINGS,
not shot attempts, so winners + forced + unforced is how many points ended on
this racket off that shot. A serve cannot be forced, so the serve row's
forced count is structurally 0 and its unforced count is the double faults.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton with the ready silhouette (headline, then rows of
label + diverging bar + ratio), aria-hidden, plus one sr-only role=status.
- empty: a plan-view court glyph and copy explaining what will appear.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: headline ratio, worst-group call-out, legend, table, footnote.
- A ready payload with no groups, or with every count at zero, falls back to
the zero state and says which of the two happened.
- Layout maths live in one exported pure function returning rows (winners,
forced, unforced, errors = unforced + forced, events, ratio, ranked), pooled
totals, the pooled ratio, the worst row, the axis extent and step.
- Ratio = winners / unforced, per group and pooled overall (pooled counts, NOT
the mean of the row ratios). unforced === 0 yields null, printed as "—":
that group is unmeasured, not infinitely good, and is excluded from the
worst-ratio comparison.
- Worst-group call-out only considers rows whose total events reach minSample;
smaller rows are drawn and labelled, and a footnote says how many were held
out. A smash missed once is a 0.00 ratio and would otherwise win that
argument against a backhand that leaked all afternoon.
- Counts that arrive negative, fractional or non-finite are repaired to whole
counts and the repair is REPORTED under the chart, never swallowed.
Rendering & styling
- One shared SYMMETRIC axis for every row: extent = the largest of any row's
winners and any row's (unforced + forced), rounded up to a nice 1/2/5 x 10^n
step (whole numbers only — a gridline at 2.5 points is not a score). Both
sides count the same thing, so a winner must be exactly as long as an error;
symmetry also guarantees each row's parity tick lands inside the frame.
- Per row, measured from the centre line: winners right in var(--chart-2);
unforced left in var(--destructive); forced continues further left in
var(--muted-foreground). Use the SOLID muted-foreground token, not a low-alpha
grey — an alpha wash survives a white card and vanishes on a near-black one.
Paint forced first and unforced over it, so a 1-point unforced count floored
to 2px stays visible; draw a 1px bg-card hairline at their join, because in
the dark theme destructive and muted-foreground sit at nearly the same
lightness and would otherwise merge.
- Parity tick: a 1px foreground-derived line on the WINNERS side at that row's
unforced count — the same number the destructive segment's length and the
printed "UE" label come from. A winners bar reaching it is a ratio of 1.00.
Never position a mark by a bucket while colouring it by its true value.
- Rows are a real <table class="table-fixed"> with a sr-only <caption> carrying
the whole summary, <th scope="row"> for the shot name and declared column
widths, so one centre line runs straight down the plot without measuring
anything; the wrapper scrolls rather than letting the track fall under 120px.
- Gridlines every step from two CSS gradients (zero extra DOM). Axis labels are
only the two extents and 0, so nothing can collide at ~420px card width.
Counts print under each bar in two exact halves (w-1/2 each side), in the
same order the segments run, with sr-only expansions of W / UE / F.
- Semantic tokens only: bg-card, border, text-muted-foreground, text-destructive,
var(--chart-2), var(--destructive), var(--muted-foreground), and color-mix on
var(--foreground) for the centre line and the parity tick. cn() merges
className, remaining props spread on the root, skeleton pulses carry
motion-reduce:animate-none, and the retry button has a focus-visible ring.
Customization levers
- Group vocabulary: the rows are whatever the feed sends, in the order it sends
them — swap tennis groups for padel (bandeja, vibora), squash, or a
serve-only breakdown, or pass one row per set instead of per shot.
- Ratio definition: winners / unforced is the coaching convention; change the
denominator to unforced + forced for a "points given away" ratio, and change
the printed decimals with the ratio formatter.
- Sample floor: minSample gates only the call-out, never the drawing; set 0 to
rank everything, or raise it for a season-long aggregate.
- Density: barHeight (10-32) plus the table's pb-3 row padding set the card's
height; drop the under-bar counts to sr-only for a compact variant, or drop
the ratio column entirely and keep the headline.
- Palette: winners take var(--chart-2) and errors var(--destructive) — remap to
any two chart tokens for a non-judgemental read (e.g. attack vs defence), but
keep the neutral third measure on a solid token, not an alpha wash.
- Scale: the axis is symmetric on purpose; if you must give the winners side
more room, scale both halves by the same points-per-pixel and move the centre
line, never stretch one side alone.Concepts
- Forced vs unforced attribution — forced errors extend the error bar but stay out of the denominator. Charging a player for a ball that was already past them is what makes a scrambler look worse than someone who bails out of every rally, so the muted segment is shown and then excluded on purpose.
- Shared symmetric scale — both halves count the same unit (points), so a winner is drawn exactly as long as an unforced error. It also guarantees every parity tick is in frame, since a row's unforced count can never exceed its error total, which can never exceed the axis extent.
- Parity tick — the mark on the winners side sits at that row's unforced count, the same number that sets the destructive segment's length and prints as "UE". Reaching it means a ratio of 1.00, which turns a division into a distance you can see. It is positioned by the value that colours and labels it, never by a bucket.
- Sample floor before ranking — a group needs
minSamplepoint endings before it can be named the worst. Four swings at a smash is not a pattern, and a chart that lets it win the call-out has ranked noise; the row is still drawn, and the footnote says how many were held out. - Pooled, not averaged — the headline ratio divides pooled winners by pooled unforced errors rather than averaging the row ratios, so a six-point overhead row cannot outvote a thirty-point forehand row in the match total.
- Zero-state that still says tennis — the empty branch draws the court in plan from the ITF measurements (23.77 m long, 8.23 m singles wide, service lines 6.40 m either side of the net, posts 0.914 m outside the doubles sideline), so the card is recognisable before any data lands.
Serve Miss Map
A four-state tennis fault map in hand-rolled SVG — both service boxes with a net band on the front edge, and net / wide / long miss channels shaded by count and labelled with count plus share, first and second serves side by side.
Return Depth
A four-state return-of-serve chart — returns grouped into depth bands laid out to real court metres, one bar per serve answered, the points-won rate overlaid on a second axis, and a verdict on whether depth converts.