Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartReturnPositionData,
  ChartReturnPositionReturn,
  ChartReturnPositionServe,
} from "./chart-return-position.contract"

export interface ChartReturnPositionProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartReturnPositionData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-return-position.json

Prompt

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.

On This Page