Charts

Doubles Formation

A four-state doubles serve-formation card — a real tennis, badminton or pickleball court in metres, one tab per formation with its usage share and win rate, and four player markers that glide to the new stance.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  DOUBLES_COURT_M,
  type ChartDoublesFormationData,
  type ChartDoublesFormationFormation,
  type ChartDoublesFormationRole,
  type ChartDoublesFormationSport,
  type DoublesCourtGeometry,
} from "./chart-doubles-formation.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartDoublesFormation" card in hand-rolled
SVG (no chart library) with zod. It shows where all four doubles players stand at
the moment of serve, one formation at a time, next to how that formation has done.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    formations: { id, label, sport, positions: { player, x, y, name? }[],
                  used, wonCount, note? }[];
    meta: { court, team, opponent?, context? } }
  where `sport` and `court` are "tennis" | "badminton" | "pickleball",
  `player` is "server" | "server-partner" | "returner" | "returner-partner",
  `used` is the service points started from the formation and `wonCount` how many
  of those the serving pair won.
- Props = z.infer of the schema plus title?, defaultFormationId?, minSample?
  (default 15), onRetry?, emptyState? and className. No parallel interface.
- Court dimensions live in the contract as a metres record, not in the component:
  tennis 23.77 x 10.97 m doubles (8.23 m singles, so a 1.37 m alley each side)
  with the service line 6.40 m from the net; badminton 13.40 x 6.10 m doubles
  (5.18 m singles) with the short service line 1.98 m from the net and the doubles
  long service line 5.94 m out; pickleball 13.41 x 6.10 m with a 2.13 m non-volley
  zone. Every line drawn and every distance printed derives from that record.

Coordinates
- One frame for the whole card, stated once: stand behind the serving pair's
  baseline. `x` runs along the court from that baseline (0 there, length / 2 at the
  net, length at the far baseline; negative means behind it, where servers stand),
  `y` runs across it from the sideline on the server's left. So the SVG is drawn
  landscape — serving pair left, net centre, receiving pair right — and every
  court renders at roughly a 2:1 ratio.
- The viewBox is the court plus a pad at each end and side, all in metres. Derive
  one unit `s` = viewBox width / 100 and size every glyph and marker in it, so a
  13.4 m badminton court and a 23.77 m tennis court print labels at the same
  on-screen size instead of one card rendering 8px text.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: pulsing formation rows plus the court silhouette, aria-hidden, with a
    visually hidden role="status" line.
  - empty: the court itself at full strength — never dimmed, because the border
    token is already the faintest line on the card and 60% of it disappears on a
    near-black one — under a heading, one line of explanation, and, when a ready
    payload carried formations but none of them could be drawn, a line saying why.
  - error: role="alert", destructive headline, "Try again" only when onRetry exists.
  - ready: verdict, formation tabs, court panel, position readout.
- The formation list IS the tab list: a vertical role="tablist" whose rows are
  role="tab" (roving tabIndex, Arrow/Home/End move selection and focus with it),
  controlling one role="tabpanel" that holds the court. Selecting a formation
  redraws the four markers.
- Selection is derived, not synced: an internal picked id wins only while it still
  exists in the payload, otherwise the fallback (defaultFormationId, else the
  most-used formation) answers. No effect, so nothing to tear down on rebuild.
- Usage shares are apportioned by largest remainder over the drawn formations, so
  the printed percentages always total exactly 100.
- Each row's bar carries both numbers: the segment's length is that formation's
  share of the service points and its filled head is that segment's win rate — so
  the filled part is also the share of ALL service points won from that formation,
  and reading the filled heads down the column adds up to the overall rate. Keep
  that property if you restyle the bar.
- The verdict is three honest sentences: the most-used formation, the best rate
  among formations with at least `minSample` service points, and a named list of
  the ones under that line ("Australian (78% of 9) sits under that line — too few
  points to call"). The best is chosen on the exact ratio, never the rounded one.
- Defensive ingest, reported not hidden: formations whose `sport` differs from
  meta.court are set aside; positions outside the drawn area are dropped rather
  than clamped onto an edge; a repeated role keeps the first stance; wonCount above
  used is repaired. Each count is stated in a role="status" line under the chart,
  and the selected formation carries its own line whenever fewer than four markers
  are drawn: "drawn", never "logged", naming which reason applies to the missing
  ones, so it can never contradict the drop counts printed below it.

Rendering & styling
- Two chart tokens and nothing else: var(--chart-1) for the serving pair and
  var(--chart-2) for the receiving pair. The role decides the team, so the same
  value paints the marker on court, the numbered badge in the readout and the
  legend chip — one formula, three consumers.
- Markers are rings: a circle filled var(--card) and stroked in the team token with
  a numeral in fill-foreground inside, so the text contrast never depends on the
  token's lightness. Numerals 1-4 are fixed by role, so switching tabs moves the
  markers without renaming them. The server carries a small filled dot on the rim:
  the ball.
- Markers sit in a <g transform="translate(x y)"> with transition-transform +
  motion-reduce:transition-none, so they glide between formations and snap when
  reduced motion is on — the position itself is a plain attribute, so nothing
  depends on the animation working.
- Court paint: stroke-border for the lines, stroke-muted-foreground for the net,
  fill-muted for the non-volley zone. The usage bar mixes the chart token toward
  var(--card) — color-mix(in oklab, var(--chart-1) 35%, var(--card)) — instead of
  using a low-alpha fill, so the bar's length survives a near-black card.
- Accessibility: the SVG is role="img" with a short label and the full detail lives
  in the visible readout plus an sr-only summary and two tables (one per formation,
  one per player). cn() merges className; numbers are tabular-nums.

Customization levers
- Court: add a sport by adding one row to the metres record (length, width, singles
  inset, service line and whether it stops at the singles sidelines, optional back
  service line and non-volley zone, centre line span, post offset, pads). Nothing
  in the component hardcodes a court.
- Density: drop the position readout for a compact card, or drop the court and keep
  the tabs for a pure formation table; both halves stand alone.
- Sample line: raise minSample for tour-level data (50+ points a set), lower it to
  1 to let every formation into the verdict.
- Marker weight: the 2.8 * s radius, 0.6 * s ring and 2.9 * s numeral scale
  together; raise them for a slide, lower them for a dense dashboard.
- Palette: TEAM_INK maps a team to a chart token — swap in var(--chart-3) /
  var(--chart-4) for a club's colours, or key it off the pair's id upstream.
- Motion: the 500ms glide is the only animation; drop the transition classes for a
  hard cut, or raise it to 800ms for a walkthrough.

Concepts

  • Selector as table — the control that switches formations is the same row that reports how that formation did, so a reader never has to hold "the one I clicked" in their head to compare it with the rest; the tab list is the analysis.
  • One frame of reference, stated once — every coordinate is metres from the serving pair's baseline and from the sideline on the server's left, so "2.5 m right of centre" means the server's right for all four players, receivers included, instead of flipping halfway across the net.
  • Geometry from the rulebook, not from pixels — the court record carries the ITF, BWF and USAP dimensions; the viewBox, the service lines, the non-volley zone and every printed distance are derived from them, and one derived unit (a hundredth of the drawing width) keeps type legible whether the court is 13.4 m or 23.77 m long.
  • Largest-remainder shares — usage percentages are floored and the leftover points handed to the largest fractional parts, so the printed shares always total exactly 100: three formations used equally often print 34 / 33 / 33, where rounding each share on its own prints 33 / 33 / 33 and leaves the reader adding up to 99.
  • Sample-size honesty — a formation under the sample line still prints its rate but is kept out of the verdict and named as kept out, because a 78% built on nine service points is a sentence about luck, not about the pattern.
  • Motion as diff — markers translate between formations rather than being redrawn, so the two players who actually moved are the two the eye follows; the stance is a plain attribute, so the card is still correct with animation off.

On This Page