Charts

Serve Placement Zones

A four-state tennis serve map — the receiver's half court in hand-rolled SVG, both service boxes cut into wide/body/T zones shaded by serve volume and labelled with count plus win rate.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServePlacementCourt,
  ChartServePlacementData,
  ChartServePlacementZoneId,
} from "./chart-serve-placement.contract"

export interface ChartServePlacementProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartServePlacementData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartServePlacement" chart — a tennis
serve placement 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";
    courts: { side: "deuce" | "ad";
              zones: { id: "wide" | "body" | "t"; label: string;
                       count: int >= 0; wonCount: int >= 0 }[] }[];
    meta: { player: string; context?: string; serveLabel?: string;
            faults?: int >= 0 } }.
- Props = z.infer of the schema plus title?, steps?, onRetry?, emptyState? and
  className. No hand-written parallel interface.
- The data is already aggregated: one row per zone, never one row per serve.
  Repeated (side, id) pairs are summed; a missing zone is a measured 0.
  Faults live in meta.faults, never as a zone with count 0 — a serve that
  missed the box has no placement to draw.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: the court silhouette with six pulsing zone blocks + a stat bar
    and a legend bar, so the card does not jump when data lands;
  - empty: a faint court and one explanatory line (also used when a ready
    payload sums to zero serves);
  - error: message + a "Try again" button rendered only when onRetry exists;
  - ready: stat line, court, legend, notes, sr-only table.
- Ingest is a pure exported function: sum repeats, drop rows whose side, id or
  numbers are unreadable, clamp wonCount down to count, and report both the
  drops and the clamps in a visible line under the chart — never silently.
- Intensity ladder: steps (2-6, default 4) cut linearly on (0, max], where max
  is the busiest zone across BOTH boxes, so the two courts stay comparable.
  A zone with 0 serves is unshaded (its own mark), not the palest shade. When
  every served zone holds the same count the ladder collapses to one step.
- Hover or keyboard focus selects a zone: the svg is role="listbox" with one
  role="option" rect per zone, tabIndex 0, arrow keys / Home / End walking the
  six zones left to right, aria-activedescendant announcing the active one.
  Arm the keyboard cursor only on :focus-visible, so a mouse click does not
  pin the tooltip with no focus ring to explain it. The tooltip is an
  aria-hidden HTML layer anchored in percentages of the svg box.
- Win rate is wonCount / count with a zero guard ("—" when nothing landed).
  Zones under ~10 serves are drawn and labelled but excluded from the
  "most productive zone" sentence, and that exclusion is stated.

Rendering & styling
- Geometry in feet, the receiver's half seen from behind the server: 36 ft
  wide by 39 ft deep, doubles rect + singles sidelines at 4.5 and 31.5 +
  service line at 21 + centre service line + centre mark; each 13.5 ft box cut
  into three 4.5 ft strips. Deuce box on the left, ad box on the right, so the
  two T zones meet at the centre line. Court lines stroke-border, the net
  stroke-muted-foreground, the analyst's zone splits dashed to separate them
  from real court markings.
- Colour comes only from tokens: one var(--chart-1) fill whose opacity steps
  (~0.12 → ~0.62) carries volume; zone text uses the foreground and
  muted-foreground fills with paintOrder="stroke" plus a var(--card) halo, so
  the count and win rate stay legible over the busiest shade in both themes.
- Panel rounded-xl border bg-card p-4; numbers tabular-nums; cn() merges
  className; the root spreads the remaining props and carries data-status.
- A11y: sr-only status on load, focus-visible outline-ring on the svg, an
  sr-only table repeating all six zones (share, points won, win rate), and
  motion-reduce:animate-none / motion-reduce:transition-none throughout.

Customization levers
- Zone split: three equal thirds is the default; change the strip widths (a
  narrow T and wide body reads more like ball-tracking bins) or go to five
  zones by extending the slot table — the ingest, legend and table follow.
- Ladder: steps 2-6 trades nuance for legibility; widen or narrow the opacity
  range (OPACITY_MIN/MAX) if your card sits on a busier surface.
- Palette: swap var(--chart-1) for another chart token, or colour the two
  courts differently by keying the fill off the slot's side.
- What is printed in a zone: name / count / win rate are three text lines —
  drop the name for a dense card, or swap the count for its share of the
  serves that landed in (name that denominator wherever the share appears,
  since faults are counted separately in the header).
- Depth: crop the drawing at the service line (viewBox height 21 + pad) for a
  compact "boxes only" variant; keep the full half for the back-court labels.
- Domain: relabel the sides and zones for another racket sport (padel,
  pickleball, badminton) — the geometry constants are the only thing tied to
  a tennis court.

Concepts

  • Aggregated zones, not dots — the contract takes one row per target, so the chart never holds a per-serve table; binning stays upstream where the tracking data lives, and the card renders the same whether it is summarising a match or a season.
  • Two measures, two channels — shade carries volume and the printed number carries outcome, deliberately kept apart: a zone can be dark and losing, and the card says so instead of blending both into one colour nobody can decode.
  • One shared intensity scale — the ladder's top is the busiest zone across both service boxes, so deuce and ad sit on the same ruler; scaling each court to its own max would make two different stories look identical.
  • Unshaded is a measured zero — "never aimed there" gets its own mark (no fill at all) rather than the palest step, and the zone still reports "0" when pointed at, so absence never reads as a faint presence.
  • Faults live outside the map — a serve that missed every box has no placement, so it is counted in the header as the in-percentage and kept out of the zones; the win rate printed inside a zone is always of serves that landed there.
  • Pointer and keyboard land on one active — hover, arrow keys and aria-activedescendant all resolve to the same index, and the keyboard cursor only arms on :focus-visible, so a mouse click never pins a tooltip that no focus ring explains.

On This Page