Badminton Shot Mix
A four-state badminton composition chart — one horizontal 100% stacked bar per game across five fixed strokes (clear, drop, smash, drive, net), largest-remainder shares, an attack-share tick (smash + drive) under every bar, per-game totals at the row end and a pooled match row.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-badminton-shot-mix.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBadmintonShotMix" chart — a
per-game 100% stacked composition bar in plain divs (no chart library), with
zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
games: { label: string;
counts: { clear: int >= 0; drop: int >= 0; smash: int >= 0;
drive: int >= 0; net: int >= 0 } }[];
meta: { player: string; context?: string } }.
- Props = z.infer of the schema plus title?, barHeight?, onRetry?,
emptyState? and className. No hand-written parallel interface.
- The five strokes are a fixed taxonomy, not a free category list — the
stacking order and the colours never re-derive from the data. Games are
drawn in the order sent and never renamed.
Behavior
- Four first-class branches in one bg-card panel: loading (the ready layout
piece for piece — stat line, legend, one full-width bar per game with its
attack tick, a divided totals row; a 100% stack is always full width, so
the skeleton silhouette is exact), empty (outlined bars + copy), error
(message + a "Try again" button only when onRetry exists), ready.
- The model is a pure exported function: counts that arrive negative,
fractional or unreadable are repaired to whole shots and the repairs
reported in a visible line — a mix whose segments were quietly invented
reads as tactics that never happened.
- Every bar is 100% of its own game, so mixes compare shape for shape across
games of different lengths; the per-game total prints at the row end in
text tokens, because the bar normalised the volume away.
- Each game's five shares are apportioned together by largest remainder so
they add to exactly 100 — never rounded one at a time. Segment WIDTHS use
the exact shares (position never comes from a rounded value); only the
printed numbers are apportioned.
- The attack tick under each bar marks smash % + drive % on the same 0–100%
axis the bar fills, computed as the sum of the two apportioned shares so a
reader adding the printed segment labels lands exactly on the tick. The
label side-flips past 80% so it never leaves the track.
- A totals row pools the whole match under a divider and gets the same bar,
tick and treatment. A game with 0 shots keeps its row as a muted hairline
— a retirement is a finding, not a missing row — with no tick, since an
attack share of nothing is not 0%.
- Hover or keyboard focus on a segment raises a tooltip (count + share of
that game) and dims the row's other segments; every segment is a
focus-visible tab stop with a full aria-label, the pointer-leave reset is
guarded so crossing segments never blinks, and the tooltip is aria-hidden
because the label already says it all. An sr-only summary + table repeat
every count, share and attack share.
Rendering & styling
- Colour from tokens only, fixed order: clear var(--chart-1), drop
var(--chart-2), smash var(--chart-3), drive var(--chart-4), net
var(--chart-5). Between segments a 2px surface gap — a card-coloured
border INSIDE each non-first segment, so the geometry stays exact instead
of gaps eating width. The attack tick is foreground ink at reduced
strength (color-mix), so it reads over any of the five fills without
joining them.
- Legend in the same fixed order with per-stroke match totals, plus a tick
chip explaining the attack marker.
- Labels and figures wear text tokens, numbers tabular-nums; panel
rounded-xl border bg-card p-4, cn() merges className, the root spreads
remaining props and carries data-status; opacity transitions respect
prefers-reduced-motion; bars, ticks and hairlines are aria-hidden.
Customization levers
- barHeight (14–32 px) trades density for presence; the label column sizes
itself to the longest game label (capped at 12ch).
- TYPE_INK / TYPE_LABEL are the single home of the taxonomy — retheme by
token swap, or adapt to another racket sport (tennis: serve / forehand /
backhand / volley / overhead) by renaming the five keys; the attack pair
is two index constants.
- The attack tick derives from SMASH_INDEX + DRIVE_INDEX; redefine "attack"
(add net kills, drop drives) by changing which apportioned shares sum
into it, or drop the tick row entirely for a plain composition read.
- The totals row is one extra renderBarRow call — remove it for a
games-only card, or feed a single game for a one-bar mix.
- The tooltip anchors at each segment's centre in track percentages;
replace it with an always-on count row if the card never gets hover.Concepts
- 100% bars trade volume for shape — normalising each game to 100 makes a 47-shot game and a 62-shot game comparable stroke for stroke, so the volume has to be printed back at the row end; a composition chart without its
nhides how much evidence each bar stands on. - Fixed taxonomy, fixed ink — the five strokes stack in one order with one colour each, never re-derived from the data, so smash is
var(--chart-3)in every row of every match and a game with no clears never silently recolours the rest. - The attack tick is a checkable sum — it marks smash + drive as the sum of the apportioned shares, on the same 0–100% axis the bar fills; a reader adding the two printed labels lands exactly on the tick, which is what makes it an annotation rather than a second data series.
- Widths are exact, labels are apportioned — segment geometry uses the raw shares (position must never come from a rounded value), while every printed percent comes from one largest-remainder pass per game so the five always add to exactly 100.
- Surface gaps that cannot lie — the 2px gap between segments is a card-coloured border inside each following segment, so the separation is visual only and the bar still spans exactly 100%; real gaps would eat width and shrink every share they touch.
- A hairline is a measured nothing — a game with 0 shots keeps its slot as a muted hairline with no attack tick, because "this game was never played" and "this game is missing from the feed" are different claims, and an attack share of nothing is not 0%.
Shuttle Landing Map
A four-state badminton landing map in hand-rolled SVG — the opponent's half drawn from BWF dimensions, every shot a dot whose colour AND shape carry the stroke (clear, drop, smash, net shot), out shots hollow past the lines, with a front/mid/rear depth panel cut on the real service lines.
Serve Spin Mix
A four-state table-tennis serve rose — five spin families at fixed semantic anchors, each wedge's area its share of serves, the win rate printed at every wedge tip and the total serve count in the hub.