Charts

Serve Miss Map

A four-state tennis fault map in hand-rolled SVG — both service boxes with a net band on the front edge, and net / wide / long miss channels shaded by count and labelled with count plus share, first and second serves side by side.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServeMissKind,
  ChartServeMissMapCourt,
  ChartServeMissMapData,
  ChartServeMissMapServe,
  ChartServeMissMapSide,
} from "./chart-serve-miss-map.contract"

export interface ChartServeMissMapProps

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartServeMissMap" chart — a tennis
serve FAULT 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";
    sides: { side: "deuce" | "ad"; serve: "first" | "second";
             inCount: int >= 0;
             misses: { kind: "net" | "wide" | "long"; count: int >= 0 }[] }[];
    meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, steps?, onRetry?, emptyState? and
  className. No hand-written parallel interface.
- Already aggregated: one row per (side, serve) pair, never one row per serve.
  Repeated pairs are summed, a missing kind is a measured 0, and a serve series
  absent from the payload drops its whole panel instead of drawing an empty
  court. inCount is context only — it is printed inside the box and never
  shaded, because a serve that landed in is not a miss.

Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
  piece for piece — stat bar, one pulsing court per serve series in the same
  auto-fit grid, legend bar and a multi-line footnote block, so the card keeps
  its height when the data lands), empty (faint court + copy,
  reused when a ready payload has nothing drawable), error (message + a
  "Try again" button only when onRetry exists), ready.
- Ingest is a pure exported function: sum repeats, drop rows whose court,
  serve or in-count is unreadable, drop miss entries whose kind or count is
  unreadable, and report both counts in a visible line — a fault map that
  quietly discards faults flatters the server it is meant to audit.
- One panel per serve series, laid out with auto-fit minmax so panels wrap to
  a column instead of squeezing. The second-serve panel carries a destructive
  badge: every miss there is a double fault, a point lost outright.
- Intensity ladder: steps (2-6, default 4) cut linearly on (0, max], where max
  is the busiest channel across BOTH courts and BOTH series, so one shade
  always means one count. A channel with 0 misses is unshaded — its own mark,
  not the palest shade — and still prints "0". When every non-empty channel
  holds the same count the ladder collapses to a single step.
- Headline: total misses of total serves struck, the first-serve percentage
  (in / (in + missed)) and the double-fault count. Every second serve exists
  because a first serve missed, so when second-serve attempts do not equal
  first-serve misses, say so instead of printing a percentage the reader can
  check and find wrong.
- A box's three shares are apportioned together by largest remainder, not
  rounded one at a time: 7 / 4 / 4 misses of 15 rounded separately prints
  47 + 27 + 27 = 101% and reads as a data error. One pre-rounded set of shares
  feeds the channel labels, the aria-label and the table, and a count too small
  for a whole percent prints "under 1%" rather than "0%".
- No hover, no tooltip, no keyboard cursor: 12 numbers fit on the court, so
  they are all printed. Each panel's svg is role="img" with a sentence-long
  aria-label, and one sr-only table repeats every count and share.

Rendering & styling
- Geometry in metres, derived from the ITF court and never from pixel guesses:
  court 23.77 m long and 8.23 m wide for singles (10.97 m doubles), so one
  service box is 8.23 / 2 = 4.115 m wide by 6.40 m deep (net to service line);
  the doubles alley is (10.97 - 8.23) / 2 = 1.37 m; the back court behind the
  service line is 23.77 / 2 - 6.40 = 5.485 m, of which the crop keeps only the
  first two alleys (2.74 m), drawn as the long band.
- Origin at the centre service line, seen from behind the server: the deuce
  box left (x -4.115..0), the ad box right (0..4.115) — a deuce serve crosses
  into the left box.
- The three channels never overlap and sit on the correct side of every line:
  net is a band on the SERVER's side of the net (negative y), because a netted
  serve has no landing point at all; its far edge is the cord itself, climbing
  0.914 m at the centre strap to 1.07 m at the posts, so the shape is a
  quadrilateral, not a rectangle. The posts stand 0.914 m OUTSIDE each doubles
  sideline, so that taper runs over 5.485 + 0.914 = 6.399 m per side and the
  cord is only ~1.014 m high where it crosses the singles sideline — measuring
  it over the sideline instead draws a net 1.83 m too narrow and a band that
  claims 1.031 m there. Wide runs outside the singles sideline, long
  runs behind the service line, and the corners where they would meet are left
  blank. Both out-channels are drawn two alleys thick (2 x 1.37 m) because a
  wide or long miss is unbounded and has no true far edge — the doubles
  sideline is still drawn one alley out, down the middle of the wide band, so
  the real scale stays visible inside it.
- Colour from tokens only: one var(--chart-1) fill whose opacity steps
  (~0.16 to ~0.72) carries the count, court lines stroke-border, the net
  stroke-muted-foreground, channel outlines dashed so a 0 channel is still a
  drawn region. Every label carries a var(--card) halo via paintOrder="stroke"
  so it reads over the darkest step in both themes.
- Label layout follows each band's shape: the net band takes one line (it is
  0.914 m tall at the strap), the long band a name over "count · share", the
  narrow wide band a name, then the count, then the share on three lines — the
  same three baselines the in-serve context uses inside the box, so the two
  line up across the card.
- Panel rounded-xl border bg-card p-4, numbers tabular-nums, cn() merges
  className, the root spreads remaining props and carries data-status.

Customization levers
- Channel thickness: CHANNEL is two alleys; widen it for a label-heavy card,
  or set it to exactly ALLEY to make wide read as the tramline it lands in.
- Crop: extend the long band to BACK_COURT (5.485 m) for a to-scale back
  court, or drop the back court entirely for a boxes-only strip.
- Ladder: steps 2-6 trades nuance for legibility; move OPACITY_MIN/MAX if your
  card sits on a busier surface; swap var(--chart-1) for another chart token,
  or key the fill off the serve series to colour double faults apart.
- Panels: the grid is one panel per serve series — force a single column, or
  facet by set / opponent instead by feeding one series and remounting.
- What is printed: kind name, count, share and the in-serve context are four
  independent text blocks; drop the share for a sparse card, or swap the
  in-serve figure for the point-win rate on serves that landed in.
- A fourth miss kind (a serve into the wrong box) needs a channel of its own:
  add it to KINDS, give it a path and an anchor, and ingest, legend and table
  follow.
- Domain: relabel the courts and kinds for padel, pickleball or badminton —
  the metre constants are the only thing tied to a tennis court.

Concepts

  • A netted serve has no landing point — it never crossed, so it cannot own ground inside the box. The net miss is drawn as a band on the server's side of the cord, at the net's own height (0.914 m at the strap, 1.07 m at the posts), which is the one honest place to put a count that has no coordinate.
  • Channels, not zones — wide and long are unbounded regions, so their bands are label areas of a stated thickness (two doubles alleys) rather than to-scale landing areas; the box, the service line at 6.40 m and the alley at 1.37 m stay to scale, and the doubles sideline is drawn inside the wide band so the real distance is visible.
  • One ladder across courts and series — a channel's count picks its step on the ramp and prints inside it, while the channel's kind alone decides where it sits, so nothing is ever positioned by a rounded or bucketed version of the value that colours it. The top of that ramp is the busiest channel anywhere on the card, so a second-serve band and a first-serve band with the same shade hold the same count; scaling each panel to its own max would make eight double faults look like thirty-one faults.
  • In-serves are context, never a channel — the figure inside each box is the serves that landed there, printed unshaded, because a serve that went in is not a miss. It is what turns the map into a serve-percentage story without letting a success sit on the failure ramp.
  • Double faults are flagged, not merged — the second-serve panel is the double-fault panel by definition, so it is labelled that way instead of being drawn as one more fault; and because every second serve follows a first-serve miss, the card checks the two series against each other and says so when the feed disagrees with itself.
  • Everything is printed, so nothing is hidden behind a hover — twelve counts fit on the court, so there is no tooltip and no keyboard cursor to learn; each panel is a single role="img" figure with a spoken summary, and an sr-only table repeats every count and share. A box's three shares are apportioned together by largest remainder, never rounded one by one: 7 / 4 / 4 misses of 15 rounded separately reads 47% + 27% + 27% = 101%, which a reader takes for a data error rather than a rounding convention.

On This Page