Charts

Court Zone Win Rate

A four-state tennis choropleth in hand-rolled SVG — the player's own half cut into a 3×3 grid derived from ITF court lines (deuce / middle / ad lanes × net / mid / baseline depths), each zone tinted by how far its point win rate sits from 50%, thin samples hatched, every zone hover- and keyboard-readable.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartCourtZoneWinrateData,
  ChartCourtZoneWinrateDepth,
  ChartCourtZoneWinrateLane,
  ChartCourtZoneWinrateZone,
} from "./chart-court-zone-winrate.contract"

export interface ChartCourtZoneWinrateProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-court-zone-winrate.json

Prompt

Build a React + TypeScript + Tailwind "ChartCourtZoneWinrate" chart — a tennis
court-zone win-rate choropleth in hand-rolled SVG (no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    zones: { lane: "deuce" | "middle" | "ad";
             depth: "net" | "mid" | "baseline";
             points: int >= 0; won: int >= 0 }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, minSample? (default 8, clamped
  1–500), steps? (2–6, default 4), onRetry?, emptyState? and className. No
  hand-written parallel interface.
- Already aggregated: up to one row per (lane, depth) pair. Repeated pairs are
  summed (a set-by-set dump is one aggregation); a pair left out renders as a
  zone with no charted points — hatched, never pretending to a rate. A won
  greater than its own points is clamped and the repair reported.

Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
  piece for piece — stat line, the half court with all nine zones pulsing,
  legend and footnote bars), empty (faint court + copy, reused when a ready
  payload has nothing charted, with the drop reason printed), error (message +
  "Try again" only when onRetry exists), ready.
- Ingest is a pure exported function: sum repeats, drop rows with unreadable
  lane/depth/counts and count the drops, exact rate per zone, sample flag,
  totals, and best/worst sampled zones on strict comparisons for the verdict
  line ("Strongest from the deuce net zone — 71% of 14; weakest from the
  middle mid-court — 33% of 9.").
- Diverging fill keyed on the EXACT rate, never the printed one: distance from
  50% picks a step on a ladder of `steps` equal cuts per side — var(--chart-2)
  above even, var(--chart-5) below, opacity ~0.18 to ~0.68. Exactly 50% keeps
  the bare court surface: dead even is its own mark. Zones under minSample are
  hatched (an SVG pattern of token strokes) and print "n<8" instead of a rate
  — one lucky volley must not be the loudest zone on the card.
- The printed rate protects the pivot: a rate that would round onto 50 from
  either side gains one decimal, truncated away from 50, so the label can
  never sit on the wrong side of the colour.
- Hover a zone → dashed frame + tooltip (zone name, rate, won of points,
  under-sample note), anchored inside the top row and above the lower rows so
  it never clips. Keyboard: the nine hit rects form one roving-tabindex
  listbox — arrows move lane-wise and depth-wise, Home/End corner to corner,
  focus-visible draws a ring-token frame. Tooltip aria-hidden; a sr-only
  role="status" region mirrors the pointer readout; a sr-only table repeats
  every record.

Rendering & styling
- Geometry in metres, derived from the ITF court and never from pixel
  guesses: half court 23.77 / 2 = 11.885 m deep by 8.23 m singles width;
  service line 6.40 m from the net (the net zone IS the service boxes);
  the mid band ends two 1.37 m doubles alleys later at 9.14 m, leaving a
  2.745 m baseline band; lanes are thirds of 8.23 m; the centre service line
  at 4.115 m and the 0.10 m centre mark are drawn as paint.
- Solid lines are paint (sidelines, baseline, service line, centre line);
  dashed lines are the chart's analytic cuts (lane thirds, the 9.14 m depth) —
  the map never dresses its own grid up as court markings. The net edge is a
  stronger stroke-muted-foreground line, overhanging the sidelines, labelled
  "Net"; the left gutter prints the depth scale (6.4 / 9.14 / 11.885 m); lanes
  are labelled under the baseline. Seen from above, behind the player — deuce
  on the right.
- Colour from tokens only: fills var(--chart-2)/var(--chart-5), court surface
  fill-muted, lines stroke-border / stroke-muted-foreground, every figure on a
  var(--card) chip so it stays contrast-safe over the deepest fill in both
  themes; text always in text tokens, never a series colour.
- Legend: a compact diverging strip — loss chips deepest-first, "← 50% →",
  win chips shallowest-first — plus a hatched chip for "under sample", the
  whole strip one role="img" with a spoken sentence. Panel rounded-xl border
  bg-card p-4, tabular-nums, cn() merges className, root spreads props and
  carries data-status, skeleton pulses honour prefers-reduced-motion.

Customization levers
- Sample floor: minSample is the honesty knob — raise it for season
  aggregates, drop it to 1 to colour everything and let the hatch disappear.
- Ladder: steps 2–6 trades nuance for legibility; OPACITY_MIN/MAX move the
  ramp for busier surfaces; swap either side's chart token (e.g. chart-3 for
  losses reads as danger in most themes).
- Grid: LANE_W and the DEPTH_EDGES array are the only zone maths — a 2-lane
  (deuce/ad) or 4-depth variant is one array edit, and the labels, hit
  targets, table and arrows follow.
- Frame: the depth scale, lane labels and net label are independent text
  blocks in CourtLines — drop any of them for a tighter card.
- Verdict: best/worst come from the ingest — replace the sentence with a
  callout chip, or key it on volume instead of rate.
- Domain: relabel lanes/depths for padel or pickleball and swap the metre
  constants — the diverging machinery is sport-agnostic.

Concepts

  • The zone is where the shot was struck from, not where it landed — this is a map of the player's own half and their hitting position when the point ended. Its landing-spot siblings (chart-tennis-depth-zones, chart-serve-placement) chart the opponent's half; its movement sibling (chart-tennis-court-coverage) charts where the player stood between shots.
  • A grid derived from paint — the net zone is the service boxes (6.40 m), the mid band is two doubles alleys more (9.14 m), lanes are thirds of the 8.23 m singles width; and the drawing keeps the distinction visible: solid lines are real court markings, dashed lines are the chart's own cuts.
  • Diverging, not sequential — win rate has a natural pivot at 50%, so the encoding runs two tints away from even rather than one tint from zero: a 62% zone and a 38% zone are equally far up their ladders, equally loud, on opposite hues. A volume ramp here would make "played a lot" masquerade as "played well".
  • Colour is keyed on the exact rate, the label protects the pivot — a 49.6% zone is tinted as a loss, so it must not print "50%": in that window the label gains a decimal truncated away from 50, and the printed number can never contradict the colour it sits on.
  • The hatch is a third state, not a faint tint — a zone under the sample floor shows texture instead of colour and prints n<8: measured, but too thin to say. Colouring a 1-of-1 zone at full opacity is how a single lucky volley ends up owning the map; and "dead even" (exactly 50%) keeps the bare surface, because it is a reading, not a weak win.
  • Every pointer readout has a keyboard twin — the nine zones are one roving-tabindex listbox: arrows move across lanes and depths the same way the eye does, each zone speaks its name, range, record and sample status, the tooltip stays aria-hidden, and a status region voices what the pointer touches.

On This Page