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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-serve-spin-mix.jsonPrompt
Build a React + TypeScript + Tailwind "ChartServeSpinMix" card — a table-tennis
serve rose with five spin families at fixed semantic anchors, drawn in
hand-rolled SVG (no chart library) with zod for the contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
spins: { family: "topspin" | "backspin" | "sidespinLeft" | "sidespinRight"
| "noSpin", used >= 0, won >= 0 }[];
meta: { player, context? } }
with refines that won <= used per entry and that families are unique.
Component props = z.infer of that schema plus title?, labels? (per-family
display-name overrides), onRetry?, emptyState? and className. No parallel
hand-written interface.
- Semantics, stated in the schema's JSDoc: `used` counts serves tagged with the
family, `won` counts how many of those serve points the server went on to
win. A family the feed does not send is read as zero serves, never as a hole.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: an aria-hidden skeleton that keeps the five-wedge silhouette,
plus one sr-only role=status line.
- empty: a plan-view table glyph (ITTF 2.74 x 1.525 m, net overhanging
15.25 cm each side, doubles centre line) and copy naming what will appear.
- error: a destructive message and a "Try again" button only when onRetry
is supplied.
- ready: the rose, a legend, a footnote; a ready payload with all counts at
zero falls back to the zero state and says the feed arrived untagged.
- Layout maths live in one exported pure function returning the five wedges
(used, won, raw share, largest-remainder sharePct, winPct or null, radius),
pooled totals, the ring domain and the most-used family.
- Printed usage shares partition a whole, so they go through largest-remainder
rounding and sum to exactly 100 in every state. Win rates are plain rounds —
they partition nothing.
- Counts that arrive negative, fractional or non-finite are repaired to whole
counts and the repair is REPORTED under the chart, never swallowed.
- Hover or keyboard focus on any wedge shows a tooltip (used, share, won,
win %); hover and focus are tracked separately so a mouse leaving never
steals a keyboard user's readout. Hit regions span hub to rim at the FULL
slot angle whatever the wedge's drawn radius, so a rarely-used family is as
easy to point at as the favourite serve.
Rendering & styling
- Five wedges of one shared 72-degree sweep at fixed anchors: topspin due up,
sidespin-right and sidespin-left flanking their own sides (72 and 288
degrees), backspin low at 144, no-spin apart at 216. Equal sweeps are
load-bearing: only then does wedge AREA mean usage share.
- Area-true radius measured from a hub: r = sqrt(hub^2 + (rim^2 - hub^2) *
share/domain), where domain is the max share rounded up a nice-step ladder
(5/10/15/20/25/50, at most 4 rings). Faint dashed reference rings carry
share labels on the seam between two wedges, with a var(--card) text halo
(paint-order: stroke) so they survive whatever they land on.
- Fills walk var(--chart-1..5) in the contract's key order at fill-opacity 0.4
with a solid 1.25px outline in the same token; a zero family keeps its
anchor as a solid muted-foreground tick at the hub.
- Win % per family prints at the wedge tip in text tokens (foreground number,
muted "won"), reached by a hairline muted leader from the tip to one shared
label ring — five labels on one radius, never chasing five different ones.
- The hub prints the total serve count (foreground, tabular-nums) over a muted
"serves" caption. The active wedge is traced with two stacked strokes,
--card under --foreground, visible on any fill.
- Coordinates are quantized to 3 decimals on their way into attributes so SSR
and the client serialise identically (V8 sin/cos differ by one ulp).
- Semantic tokens only: bg-card, border, text-muted-foreground, bg-popover for
the tooltip, var(--chart-1..5), color-mix for the translucent legend chips.
cn() merges className, remaining props spread on the root, skeleton pulses
carry motion-reduce:animate-none, decorative groups are aria-hidden, and
every hit path has role=img with a full aria-label.
Customization levers
- Family names: the labels prop renames what prints (legend, tooltip,
aria-labels) without touching the anchors — the five keys are semantic
identities, not display strings.
- Palette: fills walk var(--chart-1..5) in contract key order; remap tokens
freely but keep fill and outline on the SAME token per family, and keep the
translucent-fill-solid-outline pair so slivers stay identifiable.
- Density: RIM/HUB/label radii are viewBox units in one constants block; the
card scales to its container via max-w — tighten max-w-80 for a compact
variant, or drop the tip labels and let the tooltip carry win rates.
- Ring ladder: RING_STEPS and MAX_RINGS set how the share grid rounds; a
season-long aggregate with flat shares reads better with a 5% step.
- Anchors: the five bearings live in one FAMILY_DEF table; rotating the whole
rose is one constant per row — but keep all sweeps equal, or area stops
meaning share and the rings become lies.
- Tooltip: positioned by wedge bearing at 55% of the radius, flipping above or
below by hemisphere; swap for a fixed readout line if overlays are unwanted.Concepts
- Fixed semantic anchors — the five wedges never trade places: topspin points up, the sidespins flank their own sides, backspin sits low and no-spin sits apart. Position is part of the encoding, so two match cards can be compared by silhouette alone — a generic rose that sorts wedges by size cannot.
- Area-true radius — a wedge's ink, not its length, is its share of serves: the radius grows with the square root of the share measured from the hub. A linear radius would make a 40% family look four times a 20% family; the sqrt keeps it at exactly twice.
- Usage vs earnings, one wedge — the wedge says how often a spin is trusted, the tip says how often it paid. The most coachable pattern is a big wedge with a small tip (a habit, not a weapon) or the reverse (an under-used earner), and the card is built so that mismatch is the first thing you see.
- Largest-remainder shares — the five printed shares partition one whole, so they are rounded by largest remainder and sum to exactly 100 in every state; naive rounding drifts to 99 or 101 and the reader stops trusting every other number on the card.
- Zero is a reading, not a gap — a family with no tagged serves keeps its anchor as a tick at the hub and its full-size hit region. An empty slot would read as missing data, and pointing at a rare serve should be as easy as pointing at the favourite one.
- Equal sweeps are load-bearing — all five wedges share one 72° sweep because area only means share when angles match; the moment one wedge is widened, the same radius formula prints different areas for the same share and the labelled rings become lies.
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.
T Dominance
A four-state squash T-control card — one mirrored bar per game split at each player's share of rally time on the T around a 50% centre rule, the score under every bar, a match row on top, and the games that defied the T called out.