Break Point Ledger
A four-state per-set break-point ledger — one track per player per set sized by chances earned, a filled overlay for chances converted, "3/7 · 43%" labels, a pooled match totals row on the same scale, and hover/keyboard tooltips.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-break-point-ledger.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBreakPointLedger" card — a per-set
break-point ledger for two tennis players, drawn as track-and-fill bars 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;
sets: { id, label, a: { chances >= 0, converted >= 0 },
b: { chances >= 0, converted >= 0 } }[] }
with refines that converted <= chances on every side and that set ids are
unique. Component props = z.infer of that schema plus title?, barHeight?
(8-24, default 12), onRetry?, emptyState? and className. No parallel
hand-written interface.
- Semantics, stated in the schema's JSDoc: chances are break points earned AS
THE RETURNER; a break point saved by the server is the gap between the two
numbers, never a separate count.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton with the ready silhouette (headline, then rows of
chip + track + value), aria-hidden, plus one sr-only role=status.
- empty: two waiting muted tracks over a hairline, and copy explaining what
will appear.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: headline, legend, per-set row groups, totals row, axis, footnote.
- A ready payload with no sets, or where nobody ever reached break point,
falls back to the zero state and says which of the two happened.
- Ledger maths live in one exported pure function returning rows (per set,
per player: chances, converted, rate or null), pooled totals, the shared
extent, the gridline step and a repair count.
- Rate = converted / chances, printed as a whole percent; chances === 0
yields null, printed as "0/0 · —" over a hairline track — that lane was
measured and nothing happened, which is not the same as no data.
- Counts that arrive negative, fractional, unreadable or converting more
chances than existed are repaired and the repair is REPORTED under the
chart, never swallowed.
- Interaction: every bar (set rows and totals rows) is one focusable element
in a roving-tabindex list — one tab stop for the card, arrows/Home/End move
between bars, and hover or focus shows a floating tooltip (set, player,
chances, converted, conversion %). The hit target is the whole padded row,
far wider than the bar itself. Pointer-leave hands the tooltip back to the
focused bar instead of blanking it.
Rendering & styling
- ONE shared count scale for every bar on the card: full track width is the
larger of the two MATCH TOTALS, rounded up to a nice 1/2/5 step (whole
numbers only — a gridline at 2.5 break points is not a count). The totals
row is drawn on the same scale, so it reaches full width by construction
and a chance is exactly the same width in every row.
- Per bar: a solid bg-muted track sized by chances (never an alpha wash — it
vanishes on a dark card), overlaid from the left by the converted fill in
that player's token: var(--chart-1) for player A, var(--chart-2) for B, the
same tokens the legend chips and per-row chips use. Rounded (pill) data
ends; fills floored at 3px so a single chance never disappears.
- Gridlines from two CSS gradients (zero extra DOM): a repeating layer every
step plus one line pinned right. Axis prints only 0 and the extent.
- Values print as "converted/chances · rate%" in text tokens (foreground
fraction, muted rate), tabular-nums, in a fixed-width column so every row
aligns; labels and values never wear a series colour.
- Tooltip: a fixed-width bg-popover card floated above the track, position
clamped inside the track with CSS clamp(); aria-hidden because each bar's
own aria-label already says the same sentence.
- Accessibility: the list is role=group named by a full-text summary; each
bar is role=img with a one-sentence label; an sr-only table repeats every
number; focus-visible ring on bars; 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
- Row vocabulary: the rows are whatever the feed sends, in the order it sends
them — swap sets for matches (a season ledger), or for tiebreaks only.
- Scale policy: the shared extent is the larger match total; keep it shared
if you change it, and never scale the totals row separately from the sets.
- Density: barHeight (8-24) plus the row gap set the card's height; drop the
per-bar value column and keep only the tooltip for a compact variant.
- Palette: players take var(--chart-1) and var(--chart-2) — remap to any two
chart tokens, but keep the track a solid muted token and keep chip, fill
and legend reading from the same constant.
- Headline: breaks-from-chances is the default; swap for the two conversion
rates side by side if the consumer cares about efficiency over volume.Concepts
- Track and fill as fraction — the bar is the division itself: the muted track is the denominator (chances earned), the coloured fill the numerator (chances converted), so "3 of 7" is readable as ink before the "43%" label confirms it. The unfilled remainder is exactly the break points the server saved.
- One scale, set by the match totals — every bar shares an axis whose full width is the larger of the two match totals, so a chance is the same width in every set and the totals row reaches full width by construction. Scaling each set to its own width would make a 1/1 set look identical to a 5/5 set.
- Hairline zero-track — a player who never reached break point in a set gets a hairline and "0/0 · —", not an empty gap: "measured, and nothing happened" is information, and a missing row would read as missing data.
- Pooled totals, same drawing — the match row sums the sets and is drawn with the identical track-and-fill treatment on the identical scale, so the summary can be checked against its parts by eye rather than taken on faith.
- Roving focus with a tooltip echo — the whole card is one tab stop; arrows walk the bars, and the floating tooltip is the pointer's visible echo of the aria-label each bar already announces. The hit target is the full padded row, many times taller and wider than the 12px bar.
- Repairs in the open — counts that arrive negative, fractional or converting more than they had are clamped to something drawable, and the card prints how many were repaired instead of silently drawing a lie.
Net Clearance
A four-state side elevation of shot quality — height over the net cord against landing depth, with the coached window banded, netted balls stacked on a to-scale net and median crosshairs.
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.