Charts

Line Call Review

A challenge ledger that plots every review by the millimetres between ball and line, drawn against the paint and the ball at true scale, with each player's per-set allowance spent or still in hand.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartLineCallReviewData,
  ChartLineCallReviewItem,
  ChartLineCallReviewSide,
} from "./chart-line-call-review.contract"

export interface ChartLineCallReviewProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartLineCallReviewData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-line-call-review.json

Prompt

Build a React + TypeScript + Tailwind "ChartLineCallReview" card with zod. No
chart library: every mark is an absolutely positioned element inside a track, so
the scale is percentages of one shared domain and nothing has to be measured.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    players: { a: { name }, b: { name } };
    context?: string;
    allowancePerSet: int 1-12;
    reviews: { id, player: "a" | "b", millimetres (finite),
               overturned: boolean, scoreContext, setLabel }[] }
  with a refine for unique review ids.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and the
  native div props, spread on the root; forwardRef the root. No hand-written
  parallel interface.
- `millimetres` is the signed overlap between ball and paint, measured from the
  line's OUTER edge: +n = n mm of ball over the line (in), 0 = touching (still
  in), −n = an n mm gap (out). That one number places the mark, decides the
  in/out word and picks the margin band — never three separate fields.
- `overturned` is the review's outcome, not the ball's position. Derive the
  original call from the pair: overturned means the umpire's call was the
  opposite of what the ball did, upheld means it matched.

Behavior
- Spending rule (this is the domain, get it right): a challenge is spent only
  when the call STANDS. Tennis and badminton hand a correct challenge back, so
  `allowancePerSet` is a budget of UNSUCCESSFUL challenges. Per player per set,
  count spent = reviews with overturned false, kept = the rest, left =
  max(0, allowance − spent). Draw one pip per allowance slot, filled for spent
  and hollow for remaining; when a feed spends past the allowance (a tiebreak
  grants one extra) draw the surplus pips in destructive and say so in the
  caption rather than clamping the number away.
- Order is chronology. Keep payload order, group consecutive reviews by
  setLabel, and never re-sort by margin: the order is the only record of when
  the pressure came.
- Four first-class branches in one bg-card panel:
  - loading: a fixed silhouette — a headline bar plus three rows of label bar +
    capsule on a hairline, aria-hidden, with an sr-only role="status" line.
  - empty: explains that a challenge count says who spent them and not what
    they turned on, restates the allowance, and reports any review dropped for
    a non-finite margin. A ready payload with nothing placeable renders this
    branch, not an axis with no marks.
  - error: message plus a "Try again" button rendered only when onRetry exists.
  - ready: headline rate, per-player budget blocks, shared axis, ledger,
    readout, legend, margin table, caption.
- A "Both / A / B" radiogroup filters whose reviews are shown; a side only earns
  a chip once it has one. The millimetre domain is cut from EVERY review, never
  from the filtered ones, so switching never slides a mark that stayed.
- The ledger is a role="listbox" of role="option" rows inside role="group" set
  groups, with roving tabindex, arrow/Home/End on one delegated handler, and
  selection held as an id so a filter change cannot strand the cursor (fall back
  to the first row, so the readout is never empty). Selecting a review that was
  spent also rings the pip it cost — one datum, two views; an overturned review
  was handed back, so it rings nothing, and the caption promises only that.
- Margin bands cut from the court itself, each CLOSING on its upper bound so a
  reading of exactly 5 or 25 belongs to the tighter band and no two rows claim
  the same number: 5 mm or less (inside a certified tracking system's own
  tolerance), over 5-25 (half the paint), over 25-50 (a full line width), over
  50. Their shares are whole percents apportioned by LARGEST
  REMAINDER — floor, then hand leftovers to the biggest fractions, ties to the
  bigger count then the earlier band — so all four total exactly 100 in every
  filter state.
- Every sentence the card prints is computed: the success rate, the closest
  margin, how many reviews turned on 5 mm or less, who kept how many of how
  many, and which player left which set holding the fewest challenges.

Rendering & styling
- Semantic tokens only. Player a = var(--chart-1), b = var(--chart-2) for marks,
  pips and dots; paint = bg-muted-foreground/25; the line's outer edge =
  bg-foreground/45; the ball = a dashed border-muted-foreground/70 capsule;
  surplus pips = bg-destructive; chips = bg-primary/text-primary-foreground
  against a plain bordered one. cn() merges className.
- Draw the court to scale, not to taste: the paint band is 50 mm wide (ITF
  lines are 25-50 mm; tour courts paint 50) and the ball capsule is 67 mm across
  (65.41-68.58 mm by rule) on the very axis the readings sit on — a 1 mm call
  next to a ball sixty-seven times wider is the whole point. The domain runs
  from the widest miss minus a full ball to the far side of the paint, so no
  capsule ever clips.
- Shape carries the outcome, colour carries the player: filled diamond =
  overturned, hollow ring = upheld, and every row prints its outcome in words
  too, so the card survives greyscale.
- Axis and rows share one grid — grid-cols-[minmax(0,1fr)_5.25rem] — so a tick
  and the mark under it cannot drift apart. Tick labels anchor left/centre/right
  by position so nothing clips at the plot edge; ticks come from the smallest
  1/2/2.5/5 x 10^n step that fits ~6 labels.
- Accessibility: each option's aria-label is the full sentence (set, score,
  who reviewed, what the call was, the margin, the outcome, challenges left), so
  the visible readout beneath is aria-hidden and never read twice. Focus-visible
  rings everywhere, transitions carry motion-reduce:transition-none, and there
  are no timers, observers or listeners to clean up — layout is percentages.

Customization levers
- Sport: swap LINE_MM / BALL_MM for badminton (40 mm lines, a 5 g shuttle) or
  a wider baseline (up to 100 mm) — every band edge and the drawn paint follow
  those two constants.
- Band ladder: TOLERANCE_MM and HALF_LINE_MM are the only cut points; move them
  (or add a fifth band) and the table, the shares and the caption follow — the
  BAND_LABEL strings are hand-written, so re-word them to the new cut points and
  keep the closed-upper-bound convention.
- Density: drop the ball capsule for a pure margin strip, drop the margin table
  for a compact card, or drop the per-set budget rows and keep the totals.
- Allowance semantics: if your feed spends a challenge on every review, count
  spent from the review count instead of from `overturned` — one line in the
  model builder, and the pips re-read themselves.
- Palette: the two chart tokens are read through SIDE_INK; map them to team
  colours, or give the paint more presence with a heavier alpha on a busy card.
- The card renders no navigation: wire onRetry and any row action to your own
  data layer.

Concepts

  • One number, three jobsmillimetres places the mark, writes the "in 3 mm" / "out 12 mm" label and picks the margin band. Nothing on the card can say a ball was in while its mark sits outside the paint, because there is no second field for either to disagree with. Each band closes on its upper bound and the table's labels say so — "5 mm or less", "Over 5 – 25 mm" — so a feed's 5.0 mm reading has exactly one row that owns it.
  • Retained challenges — a review costs an allowance only when the call stands. The pips are counted from overturned, so a player who reads the lines perfectly reviews all afternoon and still holds three; a card that counted reviews taken would report the opposite of the rule.
  • True-scale court geometry — the paint is drawn at its real 50 mm and the ball at its real 67 mm on the axis the readings sit on. The domain reserves a full ball's width beyond the widest miss so no capsule clips, and the scale is cut from every review rather than the filtered ones, so switching players never slides a mark.
  • Chronology as the axis — rows keep payload order and group by set, because when a challenge was spent is the pressure story the score context exists to tell. Sorting by margin would make a tidier chart of a different match.
  • Cross-highlight, not duplication — selecting a review that cost an allowance rings the pip it spent, tying the ledger to the budget rail; an overturned review was handed straight back, so it has no pip to ring and the caption promises the highlight only for calls that stood. The readout adds only what a row cannot show, the call that was on court before the review.
  • Largest-remainder shares — the four margin bands are apportioned together, so every filter state totals exactly 100 instead of the 99 or 101 that per-row rounding prints.

On This Page