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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-deuce-ad-split.jsonPrompt
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
higherIsBetterdecides: 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-controlsalways resolves and keyboard, touch and pointer users all reach the same detail.
Shuttle Speed Decay
A four-state badminton physics chart in hand-rolled SVG — one speed-against-distance curve per shot type, each marked where the receiver's racket meets it, with the flight time integrated from the curve itself and a distance cursor you can drag or arrow along.
Tiebreak Ledger
A four-state tiebreak ledger — every tiebreak as a point-by-point strip with the serve above or below the line, mini-breaks hatched, plus a selectable scope driving the record, the hold rates and a largest-remainder split of where the points went.