Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  TENNIS_DOUBLES_WIDTH_M,
  TENNIS_HALF_DEPTH_M,
  TENNIS_NET_POST_OFFSET_M,
  TENNIS_SERVICE_LINE_M,
  TENNIS_SINGLES_WIDTH_M,
} from "./chart-return-depth.contract"
import type {
  ChartReturnDepthBand,

Installation

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

Prompt

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 of aria-pressed toggles 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.

On This Page