Return Depth
A four-state return-of-serve chart — returns grouped into depth bands laid out to real court metres, one bar per serve answered, the points-won rate overlaid on a second axis, and a verdict on whether depth converts.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-return-depth.jsonPrompt
Build a React + TypeScript + Tailwind "ChartReturnDepth" chart — a tennis
return-of-serve depth card in hand-rolled SVG (no chart library), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
bands: { id: string; label: string; fromMetres: number >= 0;
toMetres: number > fromMetres;
firstServe: { returns: int >= 0; won: int >= 0 };
secondServe: { returns: int >= 0; won: int >= 0 } }[];
meta: { player: string; context?: string;
firstServe: { label?: string; attempted: int >= 0;
averageDepthMetres: number >= 0 };
secondServe: { label?: string; attempted: int >= 0;
averageDepthMetres: number >= 0 };
serviceLineMetres?: number; baselineMetres?: number } }.
- Props = z.infer of the schema plus title?, defaultScope?, onRetry?,
emptyState? and className. No hand-written parallel interface.
- The data is aggregated: one row per band, never one row per return. Bands
arrive in any order and are sorted by fromMetres.
- Counting rules that must be stated in the JSDoc, because everything printed
depends on them: a band holds only returns that LANDED IN, so a netted or
long return has no depth and is absent; `attempted` is return points played
off that serve (double faults excluded, aces included, since an ace is a
return not put in play); `averageDepthMetres` is the true tracked mean of
the returns counted in the bands, not a band midpoint.
Geometry — derive, never guess
- Export the ITF constants and cut everything from them: court length 23.77 m,
so one half (net to baseline) is 11.885 m; service line 6.40 m from the net;
singles width 8.23 m, doubles 10.97 m; net posts 0.914 m outside the doubles
sideline (the net is 0.914 m high at centre and 1.07 m at the posts — heights
a plan view cannot show, which is why net clearance is a different chart).
- The depth axis is laid out PROPORTIONALLY TO METRES, not as equal-width
categories: a 6.40 m short band is drawn 2.3x wider than a 2.74 m deep band,
exactly as the court is. That is what lets the average-depth marker sit at
its true position.
- Under the plot, a court strip: a plan view rotated a quarter turn sharing the
same x scale — doubles rectangle, singles sidelines inset by the real alley
fraction, service line, centre service line, the 0.10 m centre mark, and a
net line overhanging by the real post offset. Say in a comment that lateral
distance is NOT to the depth scale (10.97 m at that scale is 300 units tall),
so the strip is a locator, not a map.
Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton that
keeps the control / stats / plot / court silhouette), empty (faint court plus
one explanatory line, reused when a ready payload has nothing drawable),
error (message plus a "Try again" button only when onRetry exists), ready.
- A role="radiogroup" segmented control — Both / 1st serve / 2nd serve — scopes
the bars, the rate line, all three headline stats and the table. Roving
tabindex, arrows and Home/End wrap, aria-checked on each role="radio".
- Ingest is a pure exported function: sort bands, drop rows whose edges or
counts are unreadable, clamp won down to returns, count bands that overlap
the one before them, and report all three in a visible line under the chart.
- Bars: returns per band on a left count axis with a nice 1/2/5 x 10^n step;
in "both" scope two bars per band (first serve, second serve), otherwise one.
A band with zero returns draws no bar and still reads 0 in the table.
- Overlay: one dot per band at EXACTLY won / returns on a fixed 0-100% right
axis, joined by a line that is cased in var(--card) before it is stroked, so
it separates from any bar it crosses. A dashed reference line marks the
player's own overall rate. Break the line where a band has no rate rather
than interpolating across it.
- The average-depth caret is placed at toX(averageDepthMetres) — its own metre
value — never at the centre of the band it falls in, and the band named
beside it is resolved from that same value. Check the reported mean against
the envelope the bands allow (every return on its near edge, every return on
its far edge) and say so when it falls outside instead of drawing it as if
it fitted.
- "% beyond the service line" is printed only when the service line coincides
with a band edge; otherwise the bands cannot answer that question.
- Verdict sentence: compare the deepest and shallowest bands that hold at least
10 returns; "converts" / "isn't paying" / "barely moves it" on an 8-point
margin, and state how many bands were too thin to be ranked.
Rendering & styling
- One fixed viewBox (400 x 246) that scales with the card, so label spacing is
decided once and cannot collide at an untested width; size every font against
the narrowest supported card. The band under the court strip is reserved for
the depth ticks and the axis title.
- Colour comes only from tokens: var(--chart-1) first-serve bars,
var(--chart-2) second-serve bars, var(--chart-4) rate dots and line, the
doubles rectangle stroke-border, the singles sidelines / service line /
centre service line stroke-muted-foreground, the net stroke-foreground at
double width, service box fill-muted.
Solid fills only — a low-alpha wash disappears on a near-black card. Text
over marks uses paintOrder="stroke" with a var(--card) halo.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
className; the root spreads remaining props and carries data-status.
- A11y: sr-only status while loading, role="img" plus a full sentence label on
the svg, a visible <table> that repeats every band (so it serves sighted and
screen-reader readers alike), focus-visible rings on the control and the
retry button, and motion-reduce:animate-none / transition-none throughout.
Customization levers
- Band cut: three bands is the default (net to service line, then the
service-line-to-baseline strip halved). Push to four or five, or move the
deep edge to "last 1.5 m", by changing the band edges upstream — the axis,
ticks, bars, table and verdict all follow the contract.
- Scope control: drop the radiogroup for a fixed grouped view, or extend the
scope union (forehand / backhand, slice / topspin) by adding a split to the
contract and one entry to the scopes table.
- Second axis: swap points-won for any other per-band rate (winner rate, next
shot in-play rate) by changing what feeds `rate`; the axis is already 0-100%.
- Density: drop the court strip for a bare bar chart, drop the visible table
for an sr-only one, or drop the verdict line on a dashboard tile.
- Palette: the two serve tokens and the rate token are three constants; re-map
them to brand tokens, or colour the single-scope bar by scope.
- Domain: relabel for padel or pickleball by replacing the exported court
constants — they are the only thing tied to a tennis court.Concepts
- Metre-proportional band axis — the bands are laid out to the court they were cut from, so the 6.40 m short band really is 2.3× the width of the 2.74 m deep band; equal-width categories would compress the half of the court where the cheap returns die and stretch the sliver near the baseline.
- A mark placed by the value that names it — the average-depth caret sits at
toX(averageDepthMetres), and the band printed beside it is looked up from that same number. Snapping it to the centre of its band would let 6.8 m and 8.9 m draw identically, and could park the marker on the wrong side of the service line it is being read against. - Two measures, two axes, one x — bar height is volume on a count axis and dot height is outcome on a fixed 0–100% axis; keeping them apart lets a band be tall and losing, which is the whole finding on a first-serve return.
- Reported mean, checked against its bands — a tracked mean is more precise than the buckets it is drawn over, so the card computes the shallowest and deepest mean the bands could produce and says so when the feed's number cannot be reached from them, instead of quietly placing an impossible caret.
- Scope is a radio group, not a filter toggle — Both / 1st / 2nd are mutually exclusive views of the same returns, so they get
role="radio"with a roving tabindex; a pair ofaria-pressedtoggles would allow an "everything off" state that means nothing on a match report. - In play is the denominator that is easy to lie about — a return that hit the net has no landing depth, so it never appears in a band; the in-play percentage divides band totals by return points played, which counts aces against the returner and leaves double faults out entirely.
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.
Match Score Grid
A tennis scoreboard drawn as a chart — one row per set, one cell per game marked with who served and who won it, breaks emphasised, tiebreaks tagged with their mini-score, and hold percentages counted from the same games.