Rally Length Outcome
A four-state rally-length chart — points banded by shot count, each band a bar split into won and lost, the band's win rate as a dot on a fixed 0–100% scale, the busiest band called out and a one-line verdict.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-rally-length-outcome.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRallyLengthOutcome" card — a tennis
rally-length breakdown in hand-rolled SVG (no chart library), typed by zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
buckets?: { label, minShots >= 1, maxShots?, won >= 0, lost >= 0 }[];
points?: { rallyShots >= 1, won: boolean }[];
meta: { player, context?, pointLabel? } }
- Props = z.infer of that schema plus title?, bucketStarts? (default [1, 5, 9]),
height? (plot px, clamped 140-320), onRetry? and the div's own props spread on
the root. No hand-written parallel interface.
- Counting rule, stated once and honoured everywhere: rallyShots counts every
ball struck, the serve is shot 1, the last shot counts even when it missed,
and a fault adds nothing (a double fault is a 1-shot point that was lost).
- Two input shapes, one precedence rule: pre-aggregated `buckets` are drawn
exactly as sent (the feed owns the labels and the edges); raw `points` are
binned by `bucketStarts`, whose first bound is forced to 1 so no rally can
fall outside the bands. If both arrive, buckets win and the point rows are
reported as ignored under the chart, never silently merged.
Behavior
- Pure model first (exported, testable, no pixels): sort bands by minShots,
drop rows with unreadable bounds or counts and count them, compute per band
{ total, rate = won/total or null, share }, then the totals, the strictly
busiest band (-1 when two are level), and the highest / lowest win rate among
bands holding at least a dozen points, plus how many bands that leaves —
ranking with strict comparisons means a tie parks best and worst on the same
band, and only the count separates "one band could be ranked" from "they came
out level". Shares are also apportioned to whole percents by largest
remainder, so the readout column adds up to the 100% its totals row prints
instead of the 99 or 101 that per-row rounding leaves.
- Four first-class branches in one bg-card panel: loading (skeleton in the ready
silhouette), empty (outlined bars + what will fill them), error (message plus
a Try again button only when onRetry exists), ready. A ready payload that
counts zero points degrades to the empty branch and says why.
- Ready renders, in order: headline win rate + points won + the modal band;
the plot; a legend; a one-line verdict ("strongest inside X shots ... leaks
from Y"); a readout table with the modal row shaded and a totals row; then
any footnotes for dropped, ignored or overlapping bands.
- No hover state and no keyboard cursor: every number is printed, in the plot
or in the table, so the card reads the same on a phone as under a mouse.
Rendering & styling
- Two scales, four intervals each, so both share five gridline heights: the
left axis counts points up to a nice-stepped max, the right axis is fixed
0-100% because a win rate is a share. The middle gridline therefore *is* 50%
and is drawn dashed as the break-even line — painted after the bars, because
the busiest band routinely fills the plot and an underlying reference line
would be hidden exactly where a dot has to be read against it.
- Bars: points won grounded on the axis (so band-to-band comparison is honest),
points lost stacked above; var(--chart-1) and var(--chart-3).
- Win rate: one dot per band on var(--chart-4), joined only between adjacent
bands that both have a rate — an empty band breaks the line rather than
interpolating a reading nobody measured. Each dot is placed at its exact
rate, the same number its label prints, so rounding can never put a mark on
the wrong side of the break-even line; the label gains one decimal in the
single window where rounding would read "50%" for a rate that is not 50%.
- Text over paint gets a card-coloured halo (paintOrder="stroke",
stroke="var(--card)"); the busiest band's column is washed in full-strength
fill-muted, never a low-alpha tint that disappears on a dark card.
- Only semantic tokens: bg-card, border, muted, muted-foreground, destructive,
ring and var(--chart-1..5). cn() merges className. The plot width comes from
a ResizeObserver that is disconnected on unmount; band labels drop to a
stride when columns get too narrow to hold them side by side.
- The svg is role="img" with a sentence-long aria-label; the readout table is
real DOM, so the numbers are reachable without the image.
Customization levers
- Bands: bucketStarts is the whole story for raw points — [1, 3, 5, 7, 9]
splits the serve-and-return exchange from the first ground stroke each;
[1, 5, 9, 13] adds a "very long" tail. Pre-aggregated feeds carry their own.
- Density: `height` for the plot, MAX_BAR_WIDTH for chunkier or slimmer bars,
and drop the Share column of the table on narrow cards.
- Ranking: MIN_RANKED (default 12 points) decides which bands may win or lose
the verdict; raise it for a season, lower it for a single set.
- Wording: meta.pointLabel renames the counted event ("points", "rallies",
"serves won"), and the verdict sentence is one template string.
- Palette: WON_INK / LOST_INK / RATE_INK are three chart tokens — swap them for
a fixed brand pair, keeping the rate hue away from both bar hues.
- Reference: the break-even line is fixed at 50% because that is what winning
means; re-point it at a target rate if the card is comparing to a benchmark.Concepts
- Two shapes, one precedence rule — the card accepts a feed that already banded its points and a feed that only knows each point's shot count. Whichever arrives, the model downstream is identical; when both arrive the pre-aggregated one wins and the ignored rows are printed, because a chart that quietly merged them would double-count the match.
- Bucketed volume, exact rate — the bar says how many points ended at that length, the dot says how they went. The dot's height comes from the same
won / totalthe label prints and the verdict names, so a 49.6% band can never be drawn above the break-even line; in that one rounding window the label grows a decimal rather than the mark moving. - Two scales that share their gridlines — four intervals on the point axis and four on a fixed 0–100% rate axis put the two readings on the same five heights, which makes the middle line be 50% instead of a rule floating between rows. That dashed break-even line is painted over the bars rather than under them: the busiest band often fills the plot, and a reference line hidden behind it is missing from the one column whose dot most needs comparing.
- Modal band as ground — the busiest band is washed in full-strength
mutedbehind the bars and shaded again in the table, so the "most points end here" claim is anchored in two places at once and survives a near-black card, which a low-alpha tint would not. - A verdict, not just a chart — the card names the band with the best win rate and the one with the worst, ignoring bands under a dozen points, so a reader who only reads one line still gets the finding: won inside four shots, leaked past nine.
Court Coverage
A four-state tennis court-coverage map — the player's own half in hand-rolled SVG, tracked positions binned into a dwell-density field with the average position, the median recovery spot, a baseline-distance readout and a left/right balance bar.
Serve Miss Map
A four-state tennis fault map in hand-rolled SVG — both service boxes with a net band on the front edge, and net / wide / long miss channels shaded by count and labelled with count plus share, first and second serves side by side.