Charts

Court Coverage

A four-state tennis court-coverage map — the player's own half in hand-rolled SVG, tracked positions binned into a dwell-density field with the average position, the median recovery spot, a baseline-distance readout and a left/right balance bar.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartTennisCourtCoverageCourt,
  ChartTennisCourtCoverageData,
  ChartTennisCourtCoveragePosition,
} from "./chart-tennis-court-coverage.contract"

export interface ChartTennisCourtCoverageProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartTennisCourtCoverageData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartTennisCourtCoverage" chart — a
tennis court-coverage map in hand-rolled SVG (no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    court: { format: "singles" | "doubles"; runBackM?: number;
             surface?: string };
    meta: { player: string; context?: string; dwellLabel?: string };
    positions: { x: number; y: number; weight?: number > 0 }[] }.
- Props = z.infer of the schema plus title?, steps?, onRetry?, emptyState? and
  className; forwardRef, remaining props spread on the root, data-status on it.
- Coordinates are continuous metres on the court, never bin indices: x is
  signed distance from the centre line (negative = the player's left, facing
  the net), y is distance from the net into their own half. weight is dwell —
  seconds until the next sample, or 1 — and defaults to 1.

Geometry (derive it, do not guess pixels)
- ITF court, in metres: 23.77 long so the own half is 11.885 deep; 8.23 wide
  singles and 10.97 doubles; service line 6.40 from the net; centre mark 0.10
  inside the court; net posts 0.914 outside the sideline in play (the same 3 ft
  as the net's 0.914 m centre height, which rises to 1.07 m at the posts).
- Drawn frame = one half of the standard 120 x 60 ft (36.58 x 18.29 m) fenced
  court area: 6.405 m of run-back and 3.66 m of side run-off, i.e. exactly
  18.29 m square. The svg viewBox IS the court in metres, so no scale function
  stands between a value and its mark.
- Density grid: cells target 3 ft (0.9144 m) — the greatest common divisor of
  every distance the chart classifies on (21 / 18 / 21 / 18 / 12 ft) — and the
  row and column edges are built span by span between real court lines, so the
  net, service line, baseline, centre line and doubles sidelines are all cell
  edges. No cell may straddle a line a reading is classified against.

Behavior
- Four first-class branches in one bg-card panel: loading (court silhouette
  with pulsing blobs + stat bars), empty (faint court + one explanatory line,
  reused when a ready payload has nothing plottable, with the drop reason),
  error (message + a "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function: resolve the default weight, drop
  sightings outside the drawn enclosure or with a non-finite coordinate or a
  weight <= 0, count both reasons, and report them in a visible line.
- Every printed number comes from the raw metres, never from the grid:
  weighted mean (the crosshair), weighted component-wise median (the diamond,
  the "recovery spot"), distance from the baseline as a signed phrase
  ("1.2 m behind" / "0.4 m inside" / "on the baseline"), and the left/right
  split about the centre line — a sighting exactly on the line splits evenly.
- Density ladder: steps (2-7, default 5) cut linearly on (0, maxCell]; a cell
  with no dwell is not drawn at all rather than shaded palest; when every
  occupied cell is equal the ladder collapses to one step.
- A radiogroup of depth bands — all / inside the service line / no-man's land /
  behind the baseline, bounds taken from the court's own lines — re-scopes the
  headline, the marks, the balance bar and the svg's own accessible name, and
  outlines that strip of court: whatever the picture shows, its description has
  to name the same band, or a screen reader is told about marks nobody drew.
  Arrow keys wrap and select, roving tabIndex, Home/End jump to the ends.
- Mark placement rule: the crosshair is drawn at the very mean it prints. A
  mark placed at a bucketed coordinate can land on the wrong side of the
  baseline it is measured against, so nothing routes through a cell index.
- Collision rule: on a coverage map the mean and the median usually land within
  a stride of each other — less than the two glyphs are wide — and no drawing
  order saves them, so under a clearance of about one crosshair arm plus one
  half-diagonal the diamond alone is drawn at exactly that clearance along the
  mean → median direction, with a dashed hairline from its edge back to the
  coordinate it actually holds and a sentence in the caption naming the gap.
  Only the glyph moves; every printed metre is still the median's own. The two
  names always sit on opposite sides of the pair, the dodged one following its
  diamond outward.

Rendering & styling
- Tokens only: one var(--chart-1) whose opacity steps (~0.2 → 0.9) carries
  dwell; var(--chart-2)/var(--chart-3) for the two halves of the balance bar;
  court lines stroke-muted-foreground (border is a 10%-alpha white in dark, and
  the baseline cannot be the faintest thing on the card), the net
  stroke-foreground, the enclosure and the analyst's centre line dashed. Out of
  play is said with a dash pattern, never with a lower opacity.
- Both marks are drawn twice — a var(--card) under-stroke, then the shape — and
  every in-court label uses paintOrder="stroke" with a card halo, so they stay
  legible over the busiest cell in either theme. The two are told apart by
  shape (plus vs hollow diamond) as well as by their names.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
  className; the svg is role="img" with a full sentence summary, the chips are
  a labelled radiogroup with focus-visible rings, and an sr-only table repeats
  every band's share, average, median and left/right split. The three band
  shares are apportioned by largest remainder at the precision each display
  prints — whole percents on the chips, tenths in the table — so they add to
  exactly 100 beside the "All" row.
- prefers-reduced-motion: motion-reduce:animate-none on the skeleton and
  motion-reduce:transition-none on every hover transition.

Customization levers
- Frame depth: court.runBackM crops the run-back (≈3.7 m for a phone-tripod
  feed, where the camera stops seeing the player) — it snaps to whole grid
  rows, so the alignment guarantee survives.
- Cell size: CELL is the one knob for density resolution; halve it to 1.5 ft
  for a fine tracking map (the span-by-span edges still land on court lines),
  raise it for a chunky, presentation-sized card.
- Ladder: steps 2-7 trades nuance for legibility; move OPACITY_MIN/MAX if the
  card sits on a busier surface than bg-card.
- Bands: the four depth bands are a table — add "inside the baseline by 1 m",
  or drop the radiogroup entirely for a static figure; every bound must stay a
  real court line or the grid guarantee goes with it.
- Marks: keep the crosshair and drop the median for a simpler card, or add a
  session-over-session ghost mark (a second mean at lower contrast) to show
  the average position moving forward across a block of matches. MARK_CLEARANCE
  is how far apart the two glyphs must be before the diamond stops dodging —
  raise it with the glyph sizes, drop it to zero only if you also drop one mark.
- Palette: re-map the density token per player, and the balance bar's two
  tokens per side, without touching the geometry.
- Domain: relabel the bands and swap the six constants for padel, pickleball
  or badminton — the ingest, statistics and grid construction are geometry
  agnostic.

Concepts

  • Positions in, statistics out — the contract takes raw tracked metres rather than pre-binned cells, so the card owns the binning and the maths: the same payload yields the density field, the average position, the median recovery spot, the baseline distance and the lateral balance, all from one pass over the points.
  • The grid is cut on the court's own lines — row and column edges are built span by span between the net, service line, baseline, centre line and doubles sidelines, using the 3 ft greatest common divisor of those spans. No cell straddles a boundary a reading is classified against, so a shaded cell can never sit on both sides of the baseline at once.
  • A mark is placed by the number it prints — the viewBox is the court in metres, so the crosshair sits at the mean itself and the diamond at the median itself. Routing a mark through a bin index is how a mark ends up on the wrong side of the line it is measured against. The one licensed exception is legibility: a good player's mean and median land centimetres apart, and two marks drawn on top of each other are one unreadable mark, so under a fixed clearance the diamond alone is dodged along the real mean → median direction with a hairline back to the coordinate it holds — the glyph moves, the printed metres never do.
  • Mean and median say different things — the mean is dragged toward the net by every approach and every drop-shot chase; the median is where the player actually recovered to between shots. Drawing both, with their metres printed, turns "you stand too deep" into a number a player can move.
  • Depth bands as a lens, not a recolour — picking a band re-scopes the headline, the marks and the balance bar and outlines that strip of court, while the density keeps one shared ruler; rescaling the shading per band would make a quiet forecourt look as busy as a baseline.
  • Absence is drawn as absence — an unshaded cell is a place the player never stood, not missing data, and sightings that fall outside the drawn enclosure are counted and named under the chart instead of quietly improving the numbers above it.

On This Page