Charts

Volleyball Rotation Wheel

A four-state volleyball rotation wheel that replays a match rally by rally — six slots in serve order, side-out % on the left of each slot and hatched point-scoring % on the right, driven by a play / step / scrub transport on a set-and-rotation clock.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, Pause, Play, RotateCcw, SkipBack } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartVolleyballRotationRally,
  ChartVolleyballRotationWheelData,
} from "./chart-volleyball-rotation-wheel.contract"

export interface ChartVolleyballRotationWheelProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartVolleyballRotationWheelData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-volleyball-rotation-wheel.json

Prompt

Build a React + TypeScript + Tailwind "ChartVolleyballRotationWheel" card in
hand-rolled SVG with zod and lucide-react — a volleyball match replayed rally
by rally on a six-slot rotation wheel, with a real transport.

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; team?: string; opponent?: string; context?: string;
    rallies: { set: int 1-5; rotation: int 1-6; phase: "serve" | "receive";
               won: boolean; note?: string }[] }.
- ARRAY ORDER IS THE CLOCK: rallies arrive in playing order and nothing is
  sorted. The match is rally scoring, so the scoreboard is DERIVED — the winner
  of each rally takes the point and the set score restarts whenever `set`
  changes. Never carry a score on the wire that the picture could contradict.
- Props = z.infer of the schema plus the playhead triple frame? /
  defaultFrame? / onFrameChange?(index), autoPlay?, rallyMs? (clamped
  120-5000, default 700), onRetry? and className; forwardRef the card div and
  spread the remaining native props on it. No hand-written parallel interface.
- Export the maths beside the component so a test can print the same numbers
  the picture is made of:
    ingestVolleyballRallies(rallies) -> { frames, adjusted, dropped }
    tallyThrough(frames, index) -> six rotation tallies at that instant
    rotationSlotAngles(rotation) -> { start, mid, end }
    polarPoint(cx, cy, r, deg) / annularSectorPath(...) / arcPath(...)
    rateRadius(percent) -> radius
  All pure, all deterministic, all rounded to 2dp.

Behavior — a replay, not a summary
- The playhead is an index into the ingested frames, never a wall clock, and it
  is derived on every render (round + clamp), so a shorter payload or a wild
  controlled value can never point at a rally that does not exist. Controlled
  and uncontrolled both work: without `frame` the card owns the index; with it
  the parent does and playback advances by calling onFrameChange, so a parent
  that pins the value holds the match still — which is the point of controlling
  it.
- Playback is one setTimeout owned by one effect, re-armed per rally and
  cleared on pause, on scrub, on a new payload and on unmount. Hiding the tab
  pauses (setPlaying(false)) rather than silently freezing, so the transport
  still tells the truth when the reader comes back. Reaching the last rally is
  DERIVED (`playing && !atEnd`), never a setState from an effect.
- Transport = real buttons, never gesture-only: restart, previous rally,
  play/pause (which becomes replay at the end), next rally, plus a rail that is
  role="slider". No control is ever natively `disabled` — inactive ones carry
  aria-disabled plus a handler guard, so a button can never vanish from under a
  finger or drop focus onto <body> at the last frame.
- Clock in the sport's own units: "Set 2 · R4 · serving", the set score big and
  live, plus "rally 34 of 148 · set 2, rally 21" and the rally's own outcome in
  volleyball words — point scored on serve, side-out conceded, side-out won,
  opponent scored on their serve.
- Every rate is cumulative THROUGH the playhead: side-out % = rallies won in
  serve receive / receptions, point-scoring % = rallies won on serve / serves,
  and a per-rotation differential (points won minus conceded while on court).
  A rotation that has not played a phase yet is null, never 0% — "we have not
  been there" and "we went there and lost every rally" must not draw the same
  mark.
- Edge cases are stated, never swallowed: rallies with an unusable set,
  rotation, phase or result are dropped and counted in a visible line; a set or
  rotation that is not a legal whole number is rounded and clamped back onto
  the wheel and counted separately in its own line; a ready payload with
  nothing left to draw falls through to the empty branch; over-long club names
  elide with a title attribute rather than wrapping the header.

Rendering & styling
- Geometry: a 300x300 viewBox, hub r=46 (the 0% baseline), 100% ring r=116,
  labels at r=133. Angles run CLOCKWISE FROM 12 O'CLOCK — the direction a team
  rotates — so polarPoint is (cx + r·sin θ, cy − r·cos θ) and every ring wedge
  is an annular sector: outer arc a0→a1 with sweep-flag 1, inner arc back with
  sweep-flag 0. R1 owns the slot centred on the top, R2 the next clockwise; a
  4° gap separates slots and a 3° gap splits each slot into its side-out half
  (left) and point-scoring half (right).
- Three marks per measure, so a missing rate can never pass for a low one: a
  rate is a wedge from the hub out to rateRadius(pct); a measured 0% is a solid
  hairline arc on the hub; never played is a dashed muted stub. Dashed
  reference rings sit at 25 / 50 / 75 %, solid at 100 %.
- The playhead is one outline drawn on R1's slot and rotated by the frame's
  CUMULATIVE turn (ingest adds ((next − prev) mod 6) × 60° per rally), so
  R6 → R1 travels forward 60° instead of unwinding 300° backwards.
- Colour never carries anything alone: side-out is var(--chart-1), point
  scoring is var(--chart-2) PLUS a 45° hatch pattern, and every rotation's own
  numbers are printed as text in the ledger below the wheel — a legend is a
  key, not a label. Declare the hatch pattern in each svg that paints with it.
- Tokens only (bg-card, border, bg-muted, text-muted-foreground, stroke-ring,
  stroke-foreground, var(--chart-1|2)); cn() merges className; the card carries
  data-status.
- Motion: playback works with motion off. Rates update discretely at each step,
  and the only tweens — the turning outline and the rail fill — are gated with
  motion-safe: and dropped entirely while scrubbing. Skeletons pulse with
  motion-reduce:animate-none.

Accessibility contract
- The wheel is one role="img" with a full-sentence aria-label (what the halves
  and rings mean, where the playhead is, the team's side-out and point-scoring
  rates, the best rotation). It is not a tab stop.
- The rotation ledger is the reachable label set: six toggle buttons with
  aria-pressed, one roving tab stop (tabIndex 0 on the cursor, -1 on the rest),
  ArrowLeft/Right/Up/Down wrapping, Home/End; each aria-label is a full
  sentence. Clicking pins a rotation, which outlines it with a dashed ring.
- The rail: role="slider" with aria-valuemin/max/now and an aria-valuetext that
  reads the clock; Arrow keys ±1, PageUp/PageDown ±10, Home/End, and pointer
  drag via setPointerCapture on the rail itself (no window listeners, nothing
  to leak). preventDefault only for keys actually handled.
- A polite live region announces the frame sentence whenever the reader drives
  the playhead — a step, a pause, the end of a scrub, the last rally — and stays
  silent while running, because one sentence per rally would queue faster than
  any screen reader can read it. An sr-only table repeats all six rotations at
  the current instant.

Customization levers
- Geometry: R_HUB / R_MAX / R_LABEL set the ring proportions, SLOT_GAP and
  HALF_GAP trade slot separation against wedge width; widen HALF_GAP to 6 for a
  sparser wheel, or set it to 0 and drop one measure for a single-rate wheel.
- Measures: the two halves are just two numbers from tallyThrough — swap
  point-scoring for first-ball side-out %, or serve-receive efficiency, without
  touching the drawing.
- Encoding: the hatch is one <pattern>; replace it with a dot pattern, or key
  the second measure off var(--chart-3) if your palette has the contrast.
- Pace: rallyMs (default 700) is the only playback speed; add a 0.5x/1x/2x
  segmented control beside the transport by dividing it.
- Density: the ledger is a grid of six rows that goes two-up on a @container
  query, never a viewport one — the card is routinely narrow on a wide screen,
  and a `sm:` breakpoint would halve the rows exactly then. Drop the
  differential column for a narrow card, or move it to a side column on wide
  ones.
- Format: five-set volleyball, beach doubles (two "rotations", to 21) and
  indoor 25/15 all fit — set targets are never assumed, because the score is
  derived from the rallies themselves.

Concepts

  • The playhead is derived, never a wall clock — the card takes an injected instant (frame / defaultFrame) and rounds-and-clamps it on every render, so a shorter payload or a wild controlled value can never address a rally that does not exist; a paused wheel at rally N renders identically every time, which is what makes it screenshot-stable and safe to server-render.
  • Rotation is the sport's own clock — the readout is “Set 2 · R4 · serving”, not a timestamp, because a volleyball team only moves one slot clockwise when it sides out; the ingest pass accumulates that turn, so the highlight always travels forward 60° instead of unwinding backwards through R6 → R1.
  • Side-out % versus point-scoring % — the left half of each slot is rallies won in serve receive, the right half is rallies won on serve; a rotation siding out at 70% but scoring 30% on serve is a completely different coaching problem from the reverse, and the wheel refuses to average them into one number.
  • A measured zero is not a missing rate — 0 side-outs from 4 receptions draws a solid hairline on the hub, while a rotation that has not been on court draws a dashed stub, so “we tried and failed” can never be read as “we have not been there yet”.
  • Transport as real UI — restart, step back, play/pause, step forward and a role="slider" rail, none of them ever natively disabled: inactive controls keep their box and their name behind aria-disabled plus a handler guard, so nothing vanishes from under a finger at the last rally and focus never drops to the body.
  • The ledger is the label, the legend is only a key — every rotation prints its own SO %, PT % and differential as text in a six-row ledger with one roving tab stop, so colour and hatch are redundant with words rather than load-bearing.

On This Page