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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-miss-map.jsonPrompt
Build a React + TypeScript + Tailwind "ChartServeMissMap" chart — a tennis
serve FAULT map in hand-rolled SVG (no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
sides: { side: "deuce" | "ad"; serve: "first" | "second";
inCount: int >= 0;
misses: { kind: "net" | "wide" | "long"; count: int >= 0 }[] }[];
meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, steps?, onRetry?, emptyState? and
className. No hand-written parallel interface.
- Already aggregated: one row per (side, serve) pair, never one row per serve.
Repeated pairs are summed, a missing kind is a measured 0, and a serve series
absent from the payload drops its whole panel instead of drawing an empty
court. inCount is context only — it is printed inside the box and never
shaded, because a serve that landed in is not a miss.
Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
piece for piece — stat bar, one pulsing court per serve series in the same
auto-fit grid, legend bar and a multi-line footnote block, so the card keeps
its height when the data lands), empty (faint court + copy,
reused when a ready payload has nothing drawable), error (message + a
"Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function: sum repeats, drop rows whose court,
serve or in-count is unreadable, drop miss entries whose kind or count is
unreadable, and report both counts in a visible line — a fault map that
quietly discards faults flatters the server it is meant to audit.
- One panel per serve series, laid out with auto-fit minmax so panels wrap to
a column instead of squeezing. The second-serve panel carries a destructive
badge: every miss there is a double fault, a point lost outright.
- Intensity ladder: steps (2-6, default 4) cut linearly on (0, max], where max
is the busiest channel across BOTH courts and BOTH series, so one shade
always means one count. A channel with 0 misses is unshaded — its own mark,
not the palest shade — and still prints "0". When every non-empty channel
holds the same count the ladder collapses to a single step.
- Headline: total misses of total serves struck, the first-serve percentage
(in / (in + missed)) and the double-fault count. Every second serve exists
because a first serve missed, so when second-serve attempts do not equal
first-serve misses, say so instead of printing a percentage the reader can
check and find wrong.
- A box's three shares are apportioned together by largest remainder, not
rounded one at a time: 7 / 4 / 4 misses of 15 rounded separately prints
47 + 27 + 27 = 101% and reads as a data error. One pre-rounded set of shares
feeds the channel labels, the aria-label and the table, and a count too small
for a whole percent prints "under 1%" rather than "0%".
- No hover, no tooltip, no keyboard cursor: 12 numbers fit on the court, so
they are all printed. Each panel's svg is role="img" with a sentence-long
aria-label, and one sr-only table repeats every count and share.
Rendering & styling
- Geometry in metres, derived from the ITF court and never from pixel guesses:
court 23.77 m long and 8.23 m wide for singles (10.97 m doubles), so one
service box is 8.23 / 2 = 4.115 m wide by 6.40 m deep (net to service line);
the doubles alley is (10.97 - 8.23) / 2 = 1.37 m; the back court behind the
service line is 23.77 / 2 - 6.40 = 5.485 m, of which the crop keeps only the
first two alleys (2.74 m), drawn as the long band.
- Origin at the centre service line, seen from behind the server: the deuce
box left (x -4.115..0), the ad box right (0..4.115) — a deuce serve crosses
into the left box.
- The three channels never overlap and sit on the correct side of every line:
net is a band on the SERVER's side of the net (negative y), because a netted
serve has no landing point at all; its far edge is the cord itself, climbing
0.914 m at the centre strap to 1.07 m at the posts, so the shape is a
quadrilateral, not a rectangle. The posts stand 0.914 m OUTSIDE each doubles
sideline, so that taper runs over 5.485 + 0.914 = 6.399 m per side and the
cord is only ~1.014 m high where it crosses the singles sideline — measuring
it over the sideline instead draws a net 1.83 m too narrow and a band that
claims 1.031 m there. Wide runs outside the singles sideline, long
runs behind the service line, and the corners where they would meet are left
blank. Both out-channels are drawn two alleys thick (2 x 1.37 m) because a
wide or long miss is unbounded and has no true far edge — the doubles
sideline is still drawn one alley out, down the middle of the wide band, so
the real scale stays visible inside it.
- Colour from tokens only: one var(--chart-1) fill whose opacity steps
(~0.16 to ~0.72) carries the count, court lines stroke-border, the net
stroke-muted-foreground, channel outlines dashed so a 0 channel is still a
drawn region. Every label carries a var(--card) halo via paintOrder="stroke"
so it reads over the darkest step in both themes.
- Label layout follows each band's shape: the net band takes one line (it is
0.914 m tall at the strap), the long band a name over "count · share", the
narrow wide band a name, then the count, then the share on three lines — the
same three baselines the in-serve context uses inside the box, so the two
line up across the card.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
className, the root spreads remaining props and carries data-status.
Customization levers
- Channel thickness: CHANNEL is two alleys; widen it for a label-heavy card,
or set it to exactly ALLEY to make wide read as the tramline it lands in.
- Crop: extend the long band to BACK_COURT (5.485 m) for a to-scale back
court, or drop the back court entirely for a boxes-only strip.
- Ladder: steps 2-6 trades nuance for legibility; move OPACITY_MIN/MAX if your
card sits on a busier surface; swap var(--chart-1) for another chart token,
or key the fill off the serve series to colour double faults apart.
- Panels: the grid is one panel per serve series — force a single column, or
facet by set / opponent instead by feeding one series and remounting.
- What is printed: kind name, count, share and the in-serve context are four
independent text blocks; drop the share for a sparse card, or swap the
in-serve figure for the point-win rate on serves that landed in.
- A fourth miss kind (a serve into the wrong box) needs a channel of its own:
add it to KINDS, give it a path and an anchor, and ingest, legend and table
follow.
- Domain: relabel the courts and kinds for padel, pickleball or badminton —
the metre constants are the only thing tied to a tennis court.Concepts
- A netted serve has no landing point — it never crossed, so it cannot own ground inside the box. The net miss is drawn as a band on the server's side of the cord, at the net's own height (0.914 m at the strap, 1.07 m at the posts), which is the one honest place to put a count that has no coordinate.
- Channels, not zones — wide and long are unbounded regions, so their bands are label areas of a stated thickness (two doubles alleys) rather than to-scale landing areas; the box, the service line at 6.40 m and the alley at 1.37 m stay to scale, and the doubles sideline is drawn inside the wide band so the real distance is visible.
- One ladder across courts and series — a channel's count picks its step on the ramp and prints inside it, while the channel's kind alone decides where it sits, so nothing is ever positioned by a rounded or bucketed version of the value that colours it. The top of that ramp is the busiest channel anywhere on the card, so a second-serve band and a first-serve band with the same shade hold the same count; scaling each panel to its own max would make eight double faults look like thirty-one faults.
- In-serves are context, never a channel — the figure inside each box is the serves that landed there, printed unshaded, because a serve that went in is not a miss. It is what turns the map into a serve-percentage story without letting a success sit on the failure ramp.
- Double faults are flagged, not merged — the second-serve panel is the double-fault panel by definition, so it is labelled that way instead of being drawn as one more fault; and because every second serve follows a first-serve miss, the card checks the two series against each other and says so when the feed disagrees with itself.
- Everything is printed, so nothing is hidden behind a hover — twelve counts fit on the court, so there is no tooltip and no keyboard cursor to learn; each panel is a single
role="img"figure with a spoken summary, and an sr-only table repeats every count and share. A box's three shares are apportioned together by largest remainder, never rounded one by one: 7 / 4 / 4 misses of 15 rounded separately reads 47% + 27% + 27% = 101%, which a reader takes for a data error rather than a rounding convention.
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.
Winners vs Errors
A four-state winners-and-errors ledger — one diverging bar per shot group with winners one way, unforced errors the other and forced errors extending the same bar, plus the winners-per-unforced ratio and the group that leaks most.