Charts

Serve +1 Patterns

A four-state tennis serve +1 flow in hand-rolled SVG — serve target, then the +1 wing and direction, then won or lost, joined by bands sized by points and coloured by win rate against a benchmark, with the most-used and the most-effective pattern named.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  SERVE_BOX_M,
  SERVE_TARGET_WIDTH_M,
  TENNIS_SINGLES_M,
  type ChartServePlusOneData,
  type ChartServePlusOneDirection,
  type ChartServePlusOnePattern,
  type ChartServePlusOnePlusOne,
  type ChartServePlusOneServe,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartServePlusOne" chart — the first
three shots of a tennis point as a compact three-stage flow, in hand-rolled SVG
(no chart library), with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    patterns: { side: "deuce"|"ad"; serve: "wide"|"body"|"t";
                plusOne: "forehand"|"backhand";
                direction: "crosscourt"|"down-the-line";
                count: int >= 0; wonCount: int >= 0 }[];
    meta: { player: string; context?: string; benchmark?: 0..1;
            endedBeforePlusOne?: int >= 0 } }.
- Props = z.infer of the schema plus title?, minSample?, defaultSide?,
  showSideFilter?, onRetry?, emptyState? and className. No hand-written
  parallel interface.
- The three stages are fixed by the enums, not supplied as a graph: three serve
  targets, four +1 shots (wing x direction), two outcomes. Nothing to layer,
  nothing to cycle-check.
- wonCount is a POINT count: one row is one point, so summing wonCount across
  patterns is exactly "serve +1 points won" and may be quoted as such. Clamp it
  down to count when a feed overshoots, and report the clamp.
- Points that ended on the serve or the return — aces, service winners, return
  errors, double faults — never reached a +1 ball. They live in
  meta.endedBeforePlusOne and are counted in the header, never drawn as a
  pattern with count 0.
- Serve targets are the three 1.37 m columns of a 4.12 m service box (half the
  8.23 m singles width), 6.40 m deep from the net. Derive the label copy from
  those metres; draw the nodes outer to inner as they sit in the box, never in
  rank order.

Behavior
- Four first-class branches in one bg-card panel: loading (two headline tiles,
  three pulsing stage bars with band blocks between them), empty (the outlined
  skeleton of the same three rows plus one line of copy — also used when a
  ready payload has nothing drawable), error (message + a "Try again" button
  only when onRetry exists), ready.
- Ingest is a pure exported function taking (patterns, side): count unreadable
  rows before filtering so that note reads the same in every filter state, sum
  repeats of the same tuple, hold back zero-count rows, clamp wonCount, and
  report all three counts in a visible line — never silently.
- A radiogroup picks the court: Both / Deuce / Ad. It only appears when both
  courts carry points, and it can only select a court that has points.
- Four partitions of the same points — patterns, serve targets, +1 shots,
  outcomes — are each apportioned to whole percents by LARGEST REMAINDER (ties
  to the bigger count, then the earlier slot), so every printed set adds to
  exactly 100 in every filter state. The header's "% won" reprints the
  apportioned outcome share rather than rounding won / total a second time, so
  the header, the Won node and the screen-reader summary can never disagree by
  a point on an exact half.
- Verdicts: the busiest pattern, and the highest win rate among patterns with
  at least minSample points (default 8; fall back to the busiest and say so
  when nothing clears the floor). Print the gap in the same rounded points the
  card shows, so the arithmetic on screen checks out.
- The plot is one role="listbox": each stage-1 to stage-2 band is an option
  with aria-selected and a full sentence as its label, arrow keys / Home / End
  walk them in drawing order, aria-activedescendant follows the pointer or the
  keyboard cursor, and only :focus-visible arms the cursor. A delegated
  pointerover reads data-p off the nearest band rather than one closure per
  band. Everything else in the svg is aria-hidden and pointer-transparent.
- A ResizeObserver measures the plot box; it is disconnected on unmount and
  before it re-observes a new node. No timers, no RAF.

Rendering & styling
- Layout is in CSS pixels with viewBox == measured width, so a font size is a
  font size: three 32px node bars at fixed y, 66px of ribbon gap between them,
  a 52px left gutter for the stage tags. One scale in pixels-per-point across
  all three rows, taken from the row with the most nodes; each row is centred
  on its own because it has its own number of 6px gaps. That scale also holds
  back 2px of the width for the centred strokes, so the last node on the
  busiest row keeps its whole outline — and its 2px highlight ring — inside the
  viewBox instead of ending flush on the edge.
- Band width is count * scale everywhere the band appears, and bands pack edge
  to edge inside a node, so a node's width is exactly the sum of its bands. No
  minimum width: a hairline band is a rare pattern, not a rendering floor.
- Bands are closed cubic outlines sharing one mid-height control line, so a
  band keeps its width through the turn. Outgoing bands leave a node in target
  order and arrive in source order, which is what keeps the fan from knotting.
- One colour rule: a band takes a five-step diverging ramp on the win rate of
  the points it carries, read against meta.benchmark — var(--chart-5) below,
  var(--muted-foreground) inside a +/-4 point dead band, var(--chart-2) above.
  The two bands leaving a +1 node carry all-won and all-lost points, so they
  land on the ends of that same ramp by construction rather than by a second
  palette. Nodes stay neutral (fill-muted / stroke-border) and the active one
  is marked with stroke-ring, because --accent equals --muted in this token set.
- Every band is outlined in its own ink at 0.9 opacity: a half-opacity fill of
  a chart token washes out to about 1.7:1 on a white card, while the outline
  clears 3:1 in both themes.
- Node labels degrade in three tiers by node width — name plus count and share,
  then name only, then count only — and the name outranks the count because the
  bar's width already carries the count.
- Colour is never the only channel: the readout, the legend bounds and an
  sr-only table print every rate as a number, and the outcome nodes say "Won"
  and "Lost" in words.
- Semantic tokens only, cn() merges className, the retry and filter controls
  carry focus-visible rings, and the pulse honours prefers-reduced-motion.

Customization levers
- Benchmark: meta.benchmark is the bar every band is coloured against — pass
  the player's win rate on all service points (aces included) rather than the
  chart's own average, or a tour par for the level. The +/-4 dead band and the
  +/-12 outer step are one constant each.
- Ranking floor: minSample (default 8) decides how much evidence a pattern
  needs before it can be called the most effective.
- Density: BAR_H / GAP_H / NODE_GAP set how tall and how airy the flow is; the
  three label thresholds decide when a node stops naming itself.
- Stages: the same skeleton takes any fixed three-stage taxonomy — swap the
  enums and the label tables for badminton (serve short/flick/drive into net
  kill or lift) or pickleball (serve depth into third shot into outcome).
- Filter: showSideFilter false drops the court radiogroup and locks the card to
  defaultSide; defaultSide opens on one court and falls back to both only when
  that court has no patterns.
- Palette: the ramp is two chart tokens plus a neutral. Re-map it to a
  single-hue opacity ladder when the host card already spends colour elsewhere.

Concepts

  • Fixed taxonomy, not a graph — the three stages come from the contract's enums (three serve targets, four +1 shots, two outcomes), so there is nothing to layer, nothing to cycle-check and no node payload to reconcile. A generic flow diagram has to earn its skeleton at runtime; this one knows it before the data arrives.
  • Width is volume, colour is outcome — a band is count × scale wide wherever it appears and takes its step on the ramp from the win rate of the very points it carries, so the two channels can never be read as one. Bands pack edge to edge inside a node, which is why a node's width is exactly the sum of the bands entering it.
  • One ramp, no second palette — the two bands leaving a +1 node carry points that were all won or all lost, so they land on the ends of the same diverging ramp every pattern band reads from. The outcome column is the ramp taken to its limit rather than a separate red-and-green key.
  • A bar that isn't the card's own average — patterns are coloured against meta.benchmark, meant to be what the player wins on an average service point. Diverging against the chart's own mean guarantees half the patterns look good; diverging against the serve itself asks the only question worth asking — did the third ball add anything the serve had not already earned?
  • Largest-remainder shares per filter state — patterns, serve targets, +1 shots and outcomes are four partitions of the same points, each apportioned on its own so every printed set adds to exactly 100 whether the card is showing both courts or one. Rounding each slice alone is how a twelve-row column prints 98.
  • A sample floor on the verdict — the most-effective call ignores patterns under minSample points and says so, falling back to the busiest pattern when nothing clears the floor. Three points at 100% is a coincidence, not a pattern.
  • Listbox over bands, not a tooltip layer — each pattern band is an option carrying its whole sentence, so the same reading is available to a pointer, to arrow keys and to a screen reader without a second hover-only surface; the active band is re-outlined on top so a later crossing band can never bury the one being read.

On This Page