Net Points
A four-state tennis net-approach composition card — one horizontal 100% stacked bar per way of coming in (won at net / passed / lobbed / error, largest-remainder shares), per-group n and success rate, a totals bar, segment tooltips, and a muted hairline for ways in that were never used.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-net-points.jsonPrompt
Build a React + TypeScript + Tailwind "ChartNetPoints" chart — a tennis
net-approach composition card in plain HTML/CSS bars (no chart library), with
zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
groups: { label: string;
outcomes: { wonAtNet: int >= 0; passed: int >= 0;
lobbed: int >= 0; error: int >= 0 } }[];
meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, onRetry?, emptyState? and
className. No hand-written parallel interface.
- Already aggregated: one row per way of coming in. wonAtNet is the only
winning outcome — success = wonAtNet / (all four summed) — and a group whose
outcomes are all zero is still a row: "never came in behind the backhand" is
a finding, not a missing record.
Behavior
- Four first-class branches in one bg-card panel: loading (a skeleton that
keeps the ready silhouette — headline, labelled bars, legend), empty
(outlined tracks + copy, reused when a ready payload holds only zeros, with
the reason printed), error (message + "Try again" only when onRetry exists),
ready.
- Model building is a pure exported function: counts repaired to whole
non-negative numbers (repairs reported on the card), per-group n, and shares
apportioned by largest remainder so every bar's four whole percents add to
exactly 100 — rounding each share alone prints 99 or 101 for some inputs and
reads as a data error. One share set sizes the segments, fills the tooltip
and populates the table.
- One 100% stacked bar per group in a fixed segment order — won at net,
passed, lobbed, error — plus a totals bar labelled "All approaches",
separated by a border. Per-group header prints n and the won-at-net share as
the success rate. Groups with n = 0 render a muted hairline and the words
"no approaches" instead of a bar.
- Hover a segment → tooltip with the outcome name, count of n and share,
anchored over the segment's centre and clamped inside the card. Each bar is
a roving-tabindex listbox: one tab stop, ArrowLeft/ArrowRight/Home/End walk
the rendered segments, every segment speaks a full sentence, focus-visible
draws an inset ring. The tooltip is aria-hidden; a sr-only role="status"
region mirrors the pointer readout.
- A count too small for a whole percent prints "under 1%", never "0%"; a
segment whose share apportions to 0 keeps a 2px sliver so the point is not
visually lost.
Rendering & styling
- Colour from tokens only, one per outcome everywhere it appears (segment,
legend chip, tooltip swatch): wonAtNet var(--chart-2), passed var(--chart-5),
lobbed var(--chart-4), error var(--chart-3). Neighbouring segments are
separated by a 1px border-card seam so two mid-lightness tokens stay two
segments in the dark theme. All text wears text tokens, never a series
colour.
- Bars are plain divs: a flex track h-4 rounded-sm overflow-hidden, segment
widths from the apportioned percents. Panel rounded-xl border bg-card p-4,
numbers tabular-nums, cn() merges className, the root spreads remaining
props and carries data-status. Skeleton pulses honour prefers-reduced-motion
via motion-reduce:animate-none. A sr-only table repeats every count and
share.
Customization levers
- Track height: the h-4 on the bar and the skeleton is the one density knob;
h-3 reads as a sparkline row, h-6 as a dashboard block.
- Outcomes: the four OUTCOME_* arrays (keys, labels, phrases, inks) are the
single place to relabel, retint or extend — a fifth outcome (e.g. "forced
weak reply") is one entry in each array plus a contract field.
- Rows: drop the totals bar by slicing it off allRows; reorder groups upstream
— the chart draws them as sent.
- Callouts: the header's success figure is the wonAtNet share; swap it for
points-per-approach or add a best/worst group verdict from the model.
- Domain: nothing but the labels is tennis — the same contract reads as
"how did each pick-and-roll finish" or "how did each sales channel convert"
by renaming groups and outcomes.Concepts
- Composition, not volume — every bar is 100% of its own group, so a group with 8 approaches and one with 30 are directly comparable as proportions; the n printed beside each label is what carries the volume, and the footnote says so instead of letting equal-length bars imply equal evidence.
- Fixed segment order is part of the encoding — won at net always leftmost, the approacher's own error always rightmost, so a bar reads "what I earned → what the opponent did → what I gave away" by position alone, before colour, and still reads that way in greyscale.
- Largest-remainder apportionment — the four shares of one bar are rounded together, leftover points going to the largest fractional parts: 14/5/3/4 of 26 always prints a set that sums to exactly 100, and the same set sizes the segments, fills the tooltip and populates the table, so no two surfaces can disagree.
- The zero row is drawn — a way of coming in that was never used renders as a muted hairline reading "no approaches" rather than vanishing: the coach's finding may be precisely that the player never chips and charges.
- Success is one of the shares — the per-group success rate is the bar's own won-at-net share, not an independently rounded ratio, so the number beside the bar can never disagree with the segment it summarises.
- Every pointer readout has a keyboard twin — each bar is one roving-tabindex listbox: arrows walk the segments, each speaks its outcome, count and share, the visual tooltip stays aria-hidden, and a status region voices what the pointer touches.
Serve Speed Bands
A four-state tennis serve-speed distribution in hand-rolled SVG — first and second serves binned into fixed-width bands and drawn as two overlaid stepped outlines with translucent fills, an exact median line per series, and a crosshair hover that reads both counts for the band under the pointer.
Court Zone Win Rate
A four-state tennis choropleth in hand-rolled SVG — the player's own half cut into a 3×3 grid derived from ITF court lines (deuce / middle / ad lanes × net / mid / baseline depths), each zone tinted by how far its point win rate sits from 50%, thin samples hatched, every zone hover- and keyboard-readable.