Charts

Deuce / Ad Split

A four-state deuce-versus-ad court split — one server's metrics mirrored around the centre mark, every row drawn as a rate of its own side's whole so two different sample sizes compare fairly, mixes apportioned by largest remainder, and the wide gaps flagged toward the better half.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  TENNIS_COURT_M,
  TENNIS_SERVICE_BOX_M,
  type ChartDeuceAdSplitData,
  type ChartDeuceAdSplitMetricMeta,
  type ChartDeuceAdSplitMix,
  type ChartDeuceAdSplitSide,
  type ChartDeuceAdSplitSideKey,
} from "./chart-deuce-ad-split.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-deuce-ad-split.json

Prompt

Build a React + TypeScript + Tailwind "ChartDeuceAdSplit" card — one server's
metrics split by the half of the court the point started from (deuce court and
ad court), drawn as mirrored 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";
    meta: { player, context? };
    metricMeta: { id, label, unit, higherIsBetter: boolean | null,
                  mix?: string, note? }[];
    mixes?: { id, label, whole }[];
    sides: { side: "deuce" | "ad"; metrics: Record<string, number>;
             pointsPlayed: number }[] }
  with refines: ready needs both sides and at least one metric, one entry per
  side, unique metric and mix ids, and every metricMeta.mix must name a
  declared mix. Component props = z.infer of that schema plus title?, flagAt?
  (1-50, default 10), defaultOpenMetricId?, onRetry?, emptyState? and
  className. No parallel hand-written interface.
- Semantics stated in the schema's JSDoc: unit "%" means the value ALREADY is a
  rate; any other unit means it is a count of points and names what is counted
  ("aces"). higherIsBetter may be null — a placement mix is a choice, not a
  score. Members of one mix partition that side's whole. pointsPlayed is
  required, because the deuce court starts every game and always sees the
  larger sample.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: a skeleton with the ready silhouette (two stat tiles, then
    mirrored lanes around a centre label column), aria-hidden, plus one
    sr-only role=status line.
  - empty: the court plan itself, waiting, and copy naming what will appear.
  - error: a destructive message and a "Try again" button rendered only when
    onRetry is supplied.
  - ready: side headers, tiles, blocks of lanes, legend, footnote, sr-only
    table.
  - A ready payload missing one half of the court, carrying no metrics, or
    carrying nothing comparable falls back to the zero state and says which of
    those happened.
- All panel maths live in one exported pure function returning rows (raw,
  whole, printed percent, gap, favoured side, flagged), a render plan of
  blocks, per-side samples and three counters for repaired / dropped / ignored
  input.
- ONE scale, reached three ways: every row is a rate of its own side's whole.
  A "%" metric is drawn as it stands; a standalone count is divided by that
  side's pointsPlayed; a mix member is apportioned against its mix's total for
  that side. Raw counts are never compared across sides.
- Mix shares are apportioned by LARGEST REMAINDER (floor, then hand leftover
  points to the biggest fractions, ties to the bigger count then the earlier
  member), so each side's members add to exactly 100 and the 100% strip fills
  its half exactly. Rounding members independently prints 99 and leaves a gap
  in the strip.
- The gap is the difference of the two PRINTED percents, so a flag always
  marks a length difference the reader can measure. flagAt (default 10)
  decides which rows are flagged; the flag arrow points at the better side
  (higherIsBetter picks it, so fewer double faults wins) and becomes a
  two-headed arrow when the metric declares no direction.
- Two tiles state the read: how many directional metrics each half takes, and
  the widest split with both its rates. Both sentences are computed from the
  same rows they describe.
- Missing is not zero: a metric a side never reported draws no bar and prints
  an em dash; a measured 0 draws a hairline. A mix member absent from a side
  is read as zero, because a partition cannot be apportioned around a hole,
  and the substitution is REPORTED.
- Interaction: each lane is a disclosure button (aria-expanded +
  aria-controls) that opens the counts behind the rate, the gap sentence and
  the metric's note. One row open at a time; the detail is always rendered and
  hidden with the hidden attribute so aria-controls always resolves. No
  hover-only affordance, no timers, no observers, no global listeners.

Rendering & styling
- Five lanes per row: the ad rate, the ad half, the centre label (plus the
  flag arrow), the deuce half, the deuce rate. Bars are 8px, anchored at the
  CENTRE edge and rounded only at their outer end, with a 3px floor so a 1%
  rate stays visible; rate lanes are fixed ch widths so every row aligns.
- Side is position AND hue: ad grows left in var(--chart-2), deuce right in
  var(--chart-1), because seen from behind the server the ad court IS their
  left. A small court plan drawn from ITF metres (23.77 x 8.23 m singles, the
  service line at 6.40 m, the 0.10 m centre mark) sits between the two column
  headers with the two halves tinted in those same tokens.
- Mix members keep their side's hue and separate by opacity down to a floor of
  0.5 — never a low-alpha tint that vanishes on a near-black card — and the
  same shade paints the member's bar, its strip segment and its detail chip.
- Mix blocks print a 100% strip per side: segments sized by the apportioned
  shares, separated by an inset 1px hairline in var(--card) so no segment
  loses width to a gap, with "100%" printed at both outer ends.
- Accessibility: each lane button carries a one-sentence aria-label; the
  decorative bars, strips and court plan are aria-hidden; an sr-only summary
  plus a full table repeat every figure; focus-visible ring on every button;
  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, ring, var(--chart-1), var(--chart-2), var(--card). cn()
  merges className; remaining props spread on the root.

Customization levers
- Metric vocabulary and order are the feed's: this is not tennis-specific
  machinery, so any two-way split with different sample sizes fits — forehand
  vs backhand wing, home vs away, first vs second serve, A vs B cohort.
  Rename SIDE_LABEL / SIDE_HAND and swap the court plan for your own glyph.
- flagAt (1-50) sets how loud the flags are: 5 flags almost every row, 20
  flags only the structural gaps. Setting it above the widest gap leaves the
  legend honestly saying nothing cleared the bar.
- Mixes are optional: drop the mixes array for a plain scalar panel, or send
  only mixes for a pure composition comparison — the strip and the member
  lanes both come from the same apportionment.
- Density: BAR_PX (8) and STRIP_PX (6) set bar weight, min-h-9 sets lane
  height, and dropping the two tiles or the footnote gives a terser card for
  narrow columns.
- Palette: remap the two SIDE_FILL tokens to any chart tokens; keep the same
  constant feeding the court plan, the chips, the bars and the strips so hue
  can never disagree with position. SHADE_MIN (0.5) sets how far mix members
  separate — raise it for busier mixes rather than adding a second hue.
- Emphasis: font-medium currently marks the better side, not the larger
  number. Keep that if you keep higherIsBetter, since half the rows (double
  faults, deep returns) invert the meaning of "bigger".

Concepts

  • Rate of its own whole — the deuce court starts every game, so it plays a few percent more points than the ad court; comparing raw counts hands it a head start on every row before play starts. Each row is divided by the whole that side actually had — its points played, or its mix total — which is what makes 63 deuce-court points and 55 ad-court ones comparable at all.
  • Three roads, one scale — a value that arrives as a percent is already a rate, a count becomes one by division, and a mix member becomes one by apportionment; forcing all three onto a single formula would make one of them lie, so the card converts and states the rule instead. That third road is largest-remainder: members are floored and the leftover points handed to the biggest fractions, ties to the bigger count then the earlier member. The shipped deuce-court return mix is exactly the case that catches naive rounding: 14 / 17 / 11 returns of 42 are 33.33 / 40.48 / 26.19 percent, which rounds member by member to 33 / 40 / 26 — a partition printing 99 under a heading that promises a whole, and a strip that leaves a sliver of card showing. Apportioned together it prints 33 / 41 / 26.
  • Flag on the printed number — the gap compared against the threshold is the difference of the two percents on screen, not a hidden higher-precision value, so a flagged row is always a length difference the reader can measure with a thumb. The arrow points at the BETTER side, which higherIsBetter decides: on the double-fault row the better side is the smaller bar.
  • No direction is a first-class answer — a placement mix has no better direction, so those rows are flagged as splits and credited to nobody. Colouring a serve pattern as good or bad would be the card inventing a verdict the sport does not have.
  • Missing is not zero — a metric a side never sent draws no bar and prints an em dash, a measured zero draws a hairline, and a mix member absent from a partition is read as zero and reported. Three different facts that a single blank row would flatten into one.
  • Disclosure over hover — the rate is what the row is about; the counts behind it are one press away in a panel that is always in the DOM and only hidden, so aria-controls always resolves and keyboard, touch and pointer users all reach the same detail.

On This Page