Groundstroke Depth Zones
A four-state tennis groundstroke landing map in hand-rolled SVG — the opponent's half cut into short / mid-court / deep bands across the left, middle and right thirds, shaded by volume, with the "beyond the service box" and "middle third" reads printed above it and a forehand / backhand filter that re-derives both.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tennis-depth-zones.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTennisDepthZones" card — a
groundstroke landing map over the opponent's half of a tennis court, drawn as
hand-rolled SVG (no chart library), with zod for the contract.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
shots?: { x, y, wing? }[]; // metres: x from the left singles
// sideline, y from the net
zones?: { depth: "short"|"mid"|"deep";
lateral: "left"|"middle"|"right";
count: int >= 0; wing? }[];
meta: { player; context?; shotLabel?; missed? } }
- Props = z.infer of that schema plus title?, steps?, defaultWing?,
showWingFilter?, showLandings?, onRetry?, emptyState?, className. No
hand-written parallel interface. forwardRef + spread the rest onto the root.
- Raw `shots` win over pre-aggregated `zones` when both arrive: the finer form
is the only one that can also place a dot. Repeated (depth, lateral, wing)
rows are summed.
Geometry — derive it, never guess pixels
- ITF singles court: 23.77 m long, so one half (net → baseline) is 11.885 m;
8.23 m wide for singles and 10.97 m for doubles, so each doubles alley is
(10.97 − 8.23) / 2 = 1.37 m and is out in singles; the service line sits
6.40 m from the net; net posts stand 0.914 m outside the doubles sidelines.
- Depth bands: short 0 → 3.20 m (the net half of the service box, 6.40 / 2),
mid-court 3.20 → 6.40 m, deep 6.40 → 11.885 m. They are deliberately unequal
because the court is — the deep band is the 5.485 m that is actually left
behind the service line.
- Lateral bands: thirds of the 8.23 m singles width (2.7433 m each), cut over
the singles court only; the alleys stay unshaded.
- Cuts are upper-inclusive: a ball touching the service line is still inside
the service box, so exactly 6.40 m reads "mid", never "deep".
- The viewBox is in metres. Every line, band and dot is placed by the same
metric frame, so the drawing cannot drift from the maths.
Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton that
keeps the two stat tiles + the court silhouette), empty (faint court + copy),
error (message + a Try again button only when onRetry exists), ready.
- Ready: a 3 x 3 zone grid shaded by the share of landings, each zone labelled
with its count and its percentage of the landings that came in. Apportion the
nine percentages together by largest remainder — floor, then hand the leftover
points to the largest fractions — so they add to exactly 100 in every filter
state; rounding each zone on its own prints 101% and reads as a data error.
- Two headline reads above the court, both derived from the same nine counts:
% beyond the service box = the deep row summed (higher is better);
% in the middle third = the middle column summed (lower is better). Print
the numerator and denominator beside each so the base rate is never hidden.
- Wing filter (All / Forehand / Backhand) as a roving-tabindex radiogroup that
re-derives both headline reads, every zone count and the dots. Render it only
when both wings carry at least one landing, and fall back to All when the
requested wing has none — a filter must never strand the reader in a card
they cannot leave. Landings with no wing are counted under All and reported.
- Raw landings are drawn as dots at their own metres, never at the zone centre,
and the zone each one is counted in is derived from those same metres.
- Landings outside the singles half court are dropped, counted and reported
under the chart: clamping a ball that sailed long onto the baseline would
inflate the very read the card is built on. meta.missed (netted / long /
wide) is header-only — it has no landing to draw.
- Hover or arrow-key a zone for its exact split; the readout line under the
court echoes the active zone. Every listener is React-bound; nothing to
clean up on unmount.
Rendering & styling
- Semantic tokens only: court lines stroke-border, net stroke-muted-foreground,
zone shading fill var(--chart-1) at stepped opacity (2-6 steps, default 4,
cut linearly on (0, max]), landing dots var(--chart-2), labels fill-foreground
/ fill-muted-foreground with a var(--card) halo (paintOrder="stroke") so they
survive the busiest shade. Zero hex / rgb / oklch.
- A zone nothing landed in stays unshaded — a measured 0 is its own mark, not
the palest shade of "landed there". When every occupied zone holds the same
count the ladder collapses to a single mid-ramp step.
- Analyst cuts (the lateral thirds, the 3.20 m halfway line) are dashed; real
court lines are solid — the reader can tell paint from arithmetic.
- svg role="grid" with role="row" / role="gridcell", aria-activedescendant on
the keyboard cursor, focus-visible outline, plus an sr-only summary and a
nine-row table. cn() merges className; motion-reduce disables the skeleton
pulse and the button transitions.
Customization levers
- Depth bands: the two cuts are constants (6.40 m and its half). Split the deep
band again for a four-band "deep / very deep" read, or move the shallow cut,
and the zone grid, headline sums and sr-only table follow.
- Lateral bands: thirds of the singles width. Switch to the doubles width (and
shade the alleys) for a doubles feed by moving the two cut constants.
- Density: drop the per-zone percentage line for a compact card, or drop the
landing dots (showLandings={false}) when a feed is huge.
- Ramp: steps 2-6 and the OPACITY_MIN / OPACITY_MAX pair; swap var(--chart-1)
for any chart token, or map each depth band to its own token if depth matters
more than volume.
- Reads: the two headline tiles are a small array — add a third (share in the
deep corners, share past 9 m) by summing different slots of the same counts.
- Filter axis: the radiogroup is wing-shaped; the same shape carries spin
(flat / topspin / slice) or set number when the payload labels shots that way.Concepts
- Court geometry as the coordinate system — the viewBox is metres, not pixels: 11.885 m of half court, a 6.40 m service line, 8.23 m of singles width. Every band edge is arithmetic on those numbers, so the picture and the percentages can never disagree.
- Placed by the value that counts it — a landing is drawn at its measured metres and binned from those same metres, so a dot can never sit on the deep side of the service line while being counted as mid-court.
- Two reads pointing opposite ways — deep share (higher is better) is the deep row summed; middle-third share (lower is better) is the middle column summed. Both come out of the nine numbers printed on the court, so the headline is auditable by eye.
- Filter as re-derivation, not as hiding — switching to Forehand re-runs the whole aggregation: counts, shading, dots and both headline reads. Wings the feed never labelled stay visible under All and are declared, rather than silently vanishing from the denominator.
- Out is not deep — a ball past the baseline has no zone, so it is dropped, counted and reported; balls that never landed at all live in
meta.missedand only ever appear in the header. - Measured zero vs no data — an unshaded zone means nothing landed there, which is a finding; the empty state is the same court drawn faint, so the card keeps its silhouette while it waits.
Drawdown (Underwater)
A four-state underwater chart derived from a raw level series — percent below the running peak hanging from a zero line, every dip shaded and ranked, the max-drawdown trough labelled, and a recovery read-out that flags what is still under water.
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.