Serve Placement Zones
A four-state tennis serve map — the receiver's half court in hand-rolled SVG, both service boxes cut into wide/body/T zones shaded by serve volume and labelled with count plus win rate.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-placement.jsonPrompt
Build a React + TypeScript + Tailwind "ChartServePlacement" chart — a tennis
serve placement 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";
courts: { side: "deuce" | "ad";
zones: { id: "wide" | "body" | "t"; label: string;
count: int >= 0; wonCount: int >= 0 }[] }[];
meta: { player: string; context?: string; serveLabel?: string;
faults?: int >= 0 } }.
- Props = z.infer of the schema plus title?, steps?, onRetry?, emptyState? and
className. No hand-written parallel interface.
- The data is already aggregated: one row per zone, never one row per serve.
Repeated (side, id) pairs are summed; a missing zone is a measured 0.
Faults live in meta.faults, never as a zone with count 0 — a serve that
missed the box has no placement to draw.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: the court silhouette with six pulsing zone blocks + a stat bar
and a legend bar, so the card does not jump when data lands;
- empty: a faint court and one explanatory line (also used when a ready
payload sums to zero serves);
- error: message + a "Try again" button rendered only when onRetry exists;
- ready: stat line, court, legend, notes, sr-only table.
- Ingest is a pure exported function: sum repeats, drop rows whose side, id or
numbers are unreadable, clamp wonCount down to count, and report both the
drops and the clamps in a visible line under the chart — never silently.
- Intensity ladder: steps (2-6, default 4) cut linearly on (0, max], where max
is the busiest zone across BOTH boxes, so the two courts stay comparable.
A zone with 0 serves is unshaded (its own mark), not the palest shade. When
every served zone holds the same count the ladder collapses to one step.
- Hover or keyboard focus selects a zone: the svg is role="listbox" with one
role="option" rect per zone, tabIndex 0, arrow keys / Home / End walking the
six zones left to right, aria-activedescendant announcing the active one.
Arm the keyboard cursor only on :focus-visible, so a mouse click does not
pin the tooltip with no focus ring to explain it. The tooltip is an
aria-hidden HTML layer anchored in percentages of the svg box.
- Win rate is wonCount / count with a zero guard ("—" when nothing landed).
Zones under ~10 serves are drawn and labelled but excluded from the
"most productive zone" sentence, and that exclusion is stated.
Rendering & styling
- Geometry in feet, the receiver's half seen from behind the server: 36 ft
wide by 39 ft deep, doubles rect + singles sidelines at 4.5 and 31.5 +
service line at 21 + centre service line + centre mark; each 13.5 ft box cut
into three 4.5 ft strips. Deuce box on the left, ad box on the right, so the
two T zones meet at the centre line. Court lines stroke-border, the net
stroke-muted-foreground, the analyst's zone splits dashed to separate them
from real court markings.
- Colour comes only from tokens: one var(--chart-1) fill whose opacity steps
(~0.12 → ~0.62) carries volume; zone text uses the foreground and
muted-foreground fills with paintOrder="stroke" plus a var(--card) halo, so
the count and win rate stay legible over the busiest shade in both themes.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
className; the root spreads the remaining props and carries data-status.
- A11y: sr-only status on load, focus-visible outline-ring on the svg, an
sr-only table repeating all six zones (share, points won, win rate), and
motion-reduce:animate-none / motion-reduce:transition-none throughout.
Customization levers
- Zone split: three equal thirds is the default; change the strip widths (a
narrow T and wide body reads more like ball-tracking bins) or go to five
zones by extending the slot table — the ingest, legend and table follow.
- Ladder: steps 2-6 trades nuance for legibility; widen or narrow the opacity
range (OPACITY_MIN/MAX) if your card sits on a busier surface.
- Palette: swap var(--chart-1) for another chart token, or colour the two
courts differently by keying the fill off the slot's side.
- What is printed in a zone: name / count / win rate are three text lines —
drop the name for a dense card, or swap the count for its share of the
serves that landed in (name that denominator wherever the share appears,
since faults are counted separately in the header).
- Depth: crop the drawing at the service line (viewBox height 21 + pad) for a
compact "boxes only" variant; keep the full half for the back-court labels.
- Domain: relabel the sides and zones for another racket sport (padel,
pickleball, badminton) — the geometry constants are the only thing tied to
a tennis court.Concepts
- Aggregated zones, not dots — the contract takes one row per target, so the chart never holds a per-serve table; binning stays upstream where the tracking data lives, and the card renders the same whether it is summarising a match or a season.
- Two measures, two channels — shade carries volume and the printed number carries outcome, deliberately kept apart: a zone can be dark and losing, and the card says so instead of blending both into one colour nobody can decode.
- One shared intensity scale — the ladder's top is the busiest zone across both service boxes, so deuce and ad sit on the same ruler; scaling each court to its own max would make two different stories look identical.
- Unshaded is a measured zero — "never aimed there" gets its own mark (no fill at all) rather than the palest step, and the zone still reports "0" when pointed at, so absence never reads as a faint presence.
- Faults live outside the map — a serve that missed every box has no placement, so it is counted in the header as the in-percentage and kept out of the zones; the win rate printed inside a zone is always of serves that landed there.
- Pointer and keyboard land on one
active— hover, arrow keys andaria-activedescendantall resolve to the same index, and the keyboard cursor only arms on:focus-visible, so a mouse click never pins a tooltip that no focus ring explains.
Gait Cycle Polar
A four-state polar phase chart of one cyclic movement — left and right as closed loops round 0–100% of the cycle, the gap between them shaded, phase sectors labelled at the rim, and an asymmetry index printed signed as well as absolute.
Pacing Strategy
A four-state pacing chart for one athlete against a plan — diverging bars of seconds banked or lost per split around a target baseline, with the cumulative banked time overlaid on a right axis and the finish read against the target.