Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartBreakPointLedgerData,
  ChartBreakPointLedgerSet,
} from "./chart-break-point-ledger.contract"

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

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-break-point-ledger.json

Prompt

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.

On This Page