Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartServeSpinMixData,
  ChartServeSpinMixSpin,
  ServeSpinFamily,
} from "./chart-serve-spin-mix.contract"

export interface ChartServeSpinMixProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartServeSpinMixData {

Installation

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

Prompt

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.

On This Page