Return Position
A four-state return-position map — where the returner stood at contact against first and second serves, on a to-scale ITF baseline view with per-serve median-depth rules, inside/behind shares, per-dot tooltips and a keyboard walk.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-return-position.jsonPrompt
Build a React + TypeScript + Tailwind "ChartReturnPosition" card — a to-scale
map of where a tennis returner stood at contact, first vs second serves, in
hand-rolled SVG (no chart library) with zod for the contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
meta: { player, context? };
returns: { across: -4.115..4.115, depth: -2.0..3.5, serve: 1 | 2 }[] }
Component props = z.infer of that schema plus title?, onRetry?, emptyState?
and className. No parallel hand-written interface.
- Semantics, stated in the schema's JSDoc: coordinates are METRES on the
returner's end of an ITF court — across runs along the baseline, 0 at the
centre mark, ±4.115 at the singles sidelines (singles width 8.23 m); depth
is measured from the baseline, POSITIVE BEHIND it, windowed 2.0 m inside to
3.5 m behind. This is where the returner STOOD, not where the return landed.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton mirroring the ready silhouette (headline, legend, the
court at its true aspect with placeholder dots, stats rows), aria-hidden,
plus one sr-only role=status.
- empty: the bare court lines and copy — no returns tracked yet.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: headline (% taken inside the baseline), legend, the dot map with
per-serve median rules, per-serve stats rows, footnote.
- Ingest maths live in one exported pure function: unreadable rows (non-finite
coordinate, serve not 1 or 2) are DROPPED and counted; out-of-window
coordinates are CLAMPED onto the window's edge and counted — both counts are
reported under the chart, never swallowed. Per serve: n, median depth (of
the drawn values), inside/behind counts, and an inside/behind percent pair
apportioned by largest remainder so it sums to exactly 100. "Inside" is
strictly in front of the baseline; a return on the line counts as behind.
- Interaction: each dot has an invisible hit circle ~2.5× its radius that owns
pointer events and the roving tab stop — one tab stop for the card,
arrows/Home/End walk the dots, hover or focus shows a floating tooltip
(serve, depth phrase, across phrase) and draws a ring around the active dot.
Pointer-leave hands the tooltip back to the focused dot.
Rendering & styling
- The svg viewBox is in court metres, so the map is to scale by construction:
singles sidelines solid inside the court and dashed as projections behind
the baseline, the baseline drawn stronger with the 0.10 m ITF centre mark,
dashed depth gridlines every metre, metre ticks in a left gutter and a
rotated axis title on the right reading "inside ← baseline → behind" with
"inside" at the top of the plot.
- Dots: first serve var(--chart-1), second serve var(--chart-2), each with a
2 px var(--card) ring (vector-effect: non-scaling-stroke keeps it 2 px at
any rendered width) so overlapping dots stay separable in both themes.
- Median rules: one dashed line per serve in that serve's token, spanning
sideline to sideline, with an in-plot label ("1st · 1.4 m behind") in text
tokens with a card-coloured halo — text never wears a series colour, so the
label names its serve. Serve 1 labels the left end, serve 2 the right, each
shorter than half the court, so the two can never collide; a label near the
top edge flips below its line.
- Stats rows double-keyed to the legend: chip, serve, n, median phrase,
"18% · 82%" inside/behind, all tabular-nums in text tokens.
- Tooltip: a fixed-width bg-popover card absolutely positioned over the svg by
percentage coordinates, clamped inside the plot with CSS clamp();
aria-hidden because the dot's own aria-label says the same sentence.
- Accessibility: the svg is role=group named by a full-text summary; each hit
circle is role=img with a one-sentence label ("Return of a first serve,
taken 1.4 m behind the baseline, 0.9 m left of centre."); an sr-only table
repeats the per-serve figures; skeleton pulses carry
motion-reduce:animate-none.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
text-destructive, bg-popover, stroke-ring, var(--chart-1), var(--chart-2).
cn() merges className; remaining props spread on the root.
Customization levers
- Window: DEPTH_INSIDE / DEPTH_BEHIND set the drawn band (2.0 / 3.5 m by
default) — widen for clay-court moonballers who return from 5 m back; the
clamp-and-report behaviour keeps working at any window.
- Series: the serve axis is 1 | 2 — repurpose it for any two-way split (ad vs
deuce return games, opponent A vs B) by relabelling SERVE_LABEL and
SERVE_PHRASE; keep one fixed token per series.
- Density: DOT_R and HIT_R (court metres) trade dot prominence against
overplotting; the hit circle should stay ≥2.5× the dot.
- Stats: the headline is the overall inside %; swap it for the median gap
between the two serves if the coaching story is "how much deeper on firsts".
- Palette: the two series read from one fixed two-token map — remap tokens,
but keep dots, rules, chips and stats rows reading from the same constant.Concepts
- Contact position, not landing spot — every dot is where the returner's racquet met the ball, so the card answers a footwork question ("how deep does she camp against firsts?"), not a ball-flight one. Its sibling chart-return-depth answers the other question: where the return landed on the far side.
- Metre-true canvas — the svg viewBox is denominated in court metres, so the 8.23 m singles width, the metre gridlines and every dot are to scale by construction; there is no pixel scale to drift out of proportion, and "1 m behind" is the same distance everywhere on the card.
- Median as a rule you can check — each serve's median depth is drawn as a dashed line through its own cloud: half the dots must sit on either side, which the eye can audit instantly. A mean would be dragged by one desperate lob-return; the median is the honest "typical position".
- Clamp and confess — a return outside the drawn window is pulled onto its edge rather than dropped (it happened) or drawn off-court (it would break the scale), and the card prints how many were clamped. Unreadable rows are dropped and counted separately.
- Largest-remainder pairs — each serve's inside/behind percentages are apportioned together so they sum to exactly 100; rounding each side alone is how "41% + 58%" ends up on a card that claims to split a whole.
- Generous targets over tiny marks — a 5 px dot is an impossible pointer target, so an invisible circle ~2.5× wider owns the hover and the roving tab stop; arrows walk dot to dot, and the tooltip is the visible echo of the sentence each dot already announces.
Head-to-Head History
A four-state rivalry ledger — one row per meeting on a center-spine timeline with the score and a surface chip on the winner's side, a running tally with per-surface splits, and the closing streak named in the header.
Season Results
A four-state season-results strip — one surface-coloured column per event on a fixed R64-to-W round ladder, title rings, month ticks that keep the off-season visible, a win–loss headline and per-surface event shares.