Serve +1 Patterns
A four-state tennis serve +1 flow in hand-rolled SVG — serve target, then the +1 wing and direction, then won or lost, joined by bands sized by points and coloured by win rate against a benchmark, with the most-used and the most-effective pattern named.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-plus-one.jsonPrompt
Build a React + TypeScript + Tailwind "ChartServePlusOne" chart — the first
three shots of a tennis point as a compact three-stage flow, in hand-rolled SVG
(no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
patterns: { side: "deuce"|"ad"; serve: "wide"|"body"|"t";
plusOne: "forehand"|"backhand";
direction: "crosscourt"|"down-the-line";
count: int >= 0; wonCount: int >= 0 }[];
meta: { player: string; context?: string; benchmark?: 0..1;
endedBeforePlusOne?: int >= 0 } }.
- Props = z.infer of the schema plus title?, minSample?, defaultSide?,
showSideFilter?, onRetry?, emptyState? and className. No hand-written
parallel interface.
- The three stages are fixed by the enums, not supplied as a graph: three serve
targets, four +1 shots (wing x direction), two outcomes. Nothing to layer,
nothing to cycle-check.
- wonCount is a POINT count: one row is one point, so summing wonCount across
patterns is exactly "serve +1 points won" and may be quoted as such. Clamp it
down to count when a feed overshoots, and report the clamp.
- Points that ended on the serve or the return — aces, service winners, return
errors, double faults — never reached a +1 ball. They live in
meta.endedBeforePlusOne and are counted in the header, never drawn as a
pattern with count 0.
- Serve targets are the three 1.37 m columns of a 4.12 m service box (half the
8.23 m singles width), 6.40 m deep from the net. Derive the label copy from
those metres; draw the nodes outer to inner as they sit in the box, never in
rank order.
Behavior
- Four first-class branches in one bg-card panel: loading (two headline tiles,
three pulsing stage bars with band blocks between them), empty (the outlined
skeleton of the same three rows plus one line of copy — also used when a
ready payload has nothing drawable), error (message + a "Try again" button
only when onRetry exists), ready.
- Ingest is a pure exported function taking (patterns, side): count unreadable
rows before filtering so that note reads the same in every filter state, sum
repeats of the same tuple, hold back zero-count rows, clamp wonCount, and
report all three counts in a visible line — never silently.
- A radiogroup picks the court: Both / Deuce / Ad. It only appears when both
courts carry points, and it can only select a court that has points.
- Four partitions of the same points — patterns, serve targets, +1 shots,
outcomes — are each apportioned to whole percents by LARGEST REMAINDER (ties
to the bigger count, then the earlier slot), so every printed set adds to
exactly 100 in every filter state. The header's "% won" reprints the
apportioned outcome share rather than rounding won / total a second time, so
the header, the Won node and the screen-reader summary can never disagree by
a point on an exact half.
- Verdicts: the busiest pattern, and the highest win rate among patterns with
at least minSample points (default 8; fall back to the busiest and say so
when nothing clears the floor). Print the gap in the same rounded points the
card shows, so the arithmetic on screen checks out.
- The plot is one role="listbox": each stage-1 to stage-2 band is an option
with aria-selected and a full sentence as its label, arrow keys / Home / End
walk them in drawing order, aria-activedescendant follows the pointer or the
keyboard cursor, and only :focus-visible arms the cursor. A delegated
pointerover reads data-p off the nearest band rather than one closure per
band. Everything else in the svg is aria-hidden and pointer-transparent.
- A ResizeObserver measures the plot box; it is disconnected on unmount and
before it re-observes a new node. No timers, no RAF.
Rendering & styling
- Layout is in CSS pixels with viewBox == measured width, so a font size is a
font size: three 32px node bars at fixed y, 66px of ribbon gap between them,
a 52px left gutter for the stage tags. One scale in pixels-per-point across
all three rows, taken from the row with the most nodes; each row is centred
on its own because it has its own number of 6px gaps. That scale also holds
back 2px of the width for the centred strokes, so the last node on the
busiest row keeps its whole outline — and its 2px highlight ring — inside the
viewBox instead of ending flush on the edge.
- Band width is count * scale everywhere the band appears, and bands pack edge
to edge inside a node, so a node's width is exactly the sum of its bands. No
minimum width: a hairline band is a rare pattern, not a rendering floor.
- Bands are closed cubic outlines sharing one mid-height control line, so a
band keeps its width through the turn. Outgoing bands leave a node in target
order and arrive in source order, which is what keeps the fan from knotting.
- One colour rule: a band takes a five-step diverging ramp on the win rate of
the points it carries, read against meta.benchmark — var(--chart-5) below,
var(--muted-foreground) inside a +/-4 point dead band, var(--chart-2) above.
The two bands leaving a +1 node carry all-won and all-lost points, so they
land on the ends of that same ramp by construction rather than by a second
palette. Nodes stay neutral (fill-muted / stroke-border) and the active one
is marked with stroke-ring, because --accent equals --muted in this token set.
- Every band is outlined in its own ink at 0.9 opacity: a half-opacity fill of
a chart token washes out to about 1.7:1 on a white card, while the outline
clears 3:1 in both themes.
- Node labels degrade in three tiers by node width — name plus count and share,
then name only, then count only — and the name outranks the count because the
bar's width already carries the count.
- Colour is never the only channel: the readout, the legend bounds and an
sr-only table print every rate as a number, and the outcome nodes say "Won"
and "Lost" in words.
- Semantic tokens only, cn() merges className, the retry and filter controls
carry focus-visible rings, and the pulse honours prefers-reduced-motion.
Customization levers
- Benchmark: meta.benchmark is the bar every band is coloured against — pass
the player's win rate on all service points (aces included) rather than the
chart's own average, or a tour par for the level. The +/-4 dead band and the
+/-12 outer step are one constant each.
- Ranking floor: minSample (default 8) decides how much evidence a pattern
needs before it can be called the most effective.
- Density: BAR_H / GAP_H / NODE_GAP set how tall and how airy the flow is; the
three label thresholds decide when a node stops naming itself.
- Stages: the same skeleton takes any fixed three-stage taxonomy — swap the
enums and the label tables for badminton (serve short/flick/drive into net
kill or lift) or pickleball (serve depth into third shot into outcome).
- Filter: showSideFilter false drops the court radiogroup and locks the card to
defaultSide; defaultSide opens on one court and falls back to both only when
that court has no patterns.
- Palette: the ramp is two chart tokens plus a neutral. Re-map it to a
single-hue opacity ladder when the host card already spends colour elsewhere.Concepts
- Fixed taxonomy, not a graph — the three stages come from the contract's enums (three serve targets, four +1 shots, two outcomes), so there is nothing to layer, nothing to cycle-check and no node payload to reconcile. A generic flow diagram has to earn its skeleton at runtime; this one knows it before the data arrives.
- Width is volume, colour is outcome — a band is
count × scalewide wherever it appears and takes its step on the ramp from the win rate of the very points it carries, so the two channels can never be read as one. Bands pack edge to edge inside a node, which is why a node's width is exactly the sum of the bands entering it. - One ramp, no second palette — the two bands leaving a +1 node carry points that were all won or all lost, so they land on the ends of the same diverging ramp every pattern band reads from. The outcome column is the ramp taken to its limit rather than a separate red-and-green key.
- A bar that isn't the card's own average — patterns are coloured against
meta.benchmark, meant to be what the player wins on an average service point. Diverging against the chart's own mean guarantees half the patterns look good; diverging against the serve itself asks the only question worth asking — did the third ball add anything the serve had not already earned? - Largest-remainder shares per filter state — patterns, serve targets, +1 shots and outcomes are four partitions of the same points, each apportioned on its own so every printed set adds to exactly 100 whether the card is showing both courts or one. Rounding each slice alone is how a twelve-row column prints 98.
- A sample floor on the verdict — the most-effective call ignores patterns under
minSamplepoints and says so, falling back to the busiest pattern when nothing clears the floor. Three points at 100% is a coincidence, not a pattern. - Listbox over bands, not a tooltip layer — each pattern band is an
optioncarrying its whole sentence, so the same reading is available to a pointer, to arrow keys and to a screen reader without a second hover-only surface; the active band is re-outlined on top so a later crossing band can never bury the one being read.
Draw Path
A four-state seeded route through a draw — one round per rail station with the projected opponent's rating and head-to-head, faint branches for whoever could arrive instead, Elo win chances compounded into a survival bar, and a tab panel for each round's whole field.
Rally Pattern Tree
An icicle tree of shot sequences rooted at the serve — branch width is the rallies that took it, tint is the win rate down it, and everything under the sample cut is pruned with its share stated.