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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-doubles-formation.jsonPrompt
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.
Stamina Decay
A four-state stamina card — several match metrics stacked as small multiples over one shared minute axis, each read against its own opening-window baseline, with the drop-off after a chosen cut quantified in the metric's own unit and as a share of that baseline.
Shuttle Speed Decay
A four-state badminton physics chart in hand-rolled SVG — one speed-against-distance curve per shot type, each marked where the receiver's racket meets it, with the flight time integrated from the curve itself and a distance cursor you can drag or arrow along.