Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartWinnersErrorsData, ChartWinnersErrorsGroup } from "./chart-winners-errors.contract"

export interface ChartWinnersErrorsProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartWinnersErrorsData {
  /** Card heading — what the chart is. Whose numbers they are comes from `meta`. */
  title?: string
  /**
   * How many points a group must have ended before it can be called the worst

Installation

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

Prompt

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 minSample point 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.

On This Page