Charts

Chart Volleyball Attack Lanes

A four-state volleyball attack-lane replay in hand-rolled SVG — tapered corridors run out of the setter's hands to each hitter and across the net, thickening swing by swing as the match replays under a set / score / rotation transport.

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 {
  ChartVolleyballAttackLanesData,
  ChartVolleyballAttackLanesLane,
  ChartVolleyballAttackLanesOutcome,
  ChartVolleyballAttackLanesPoint,
  ChartVolleyballAttackLanesSwing,
} from "./chart-volleyball-attack-lanes.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartVolleyballAttackLanes" component in
hand-rolled SVG (no chart library), with zod and lucide-react.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    match?: string; setter?: Point;
    lanes: { id; name; attacker; launch: Point; land: Point }[];
    swings: { laneId; outcome: "kill" | "error" | "dug"; set: number;
              us: number; them: number; rotation?: number;
              phase?: "sideout" | "transition" }[] }
  where Point is { x: number; y: number } in metres on a full FIVB court:
  x 0-18 from the attacking team's end line with the net at 9, y 0-9 from
  their left sideline. Lanes carry the geometry and the vocabulary; swings
  carry the match, in rally order.
- Props = z.infer of the schema plus a playhead triple (index, defaultIndex,
  onIndexChange), autoPlay (default false), swingMs (default 1100, clamped
  200-10000), weight: "attempts" | "kills" (default "attempts"), onRetry and
  className. forwardRef the root div and spread the remaining native props.
- Every figure on the card derives from one exported pure function,
  tallyAttackLanes(lanes, swings, through), so a test can print the same
  numbers the picture is made of.

Behavior
- Four first-class branches in one bg-card panel: a loading skeleton shaped
  like the ready card (clock, court block, transport row, lane rows) with a
  sr-only role="status"; an empty branch that draws the bare court; an error
  branch with a Try again button rendered only when onRetry exists; and the
  ready branch. status "ready" with no lanes or no swings falls through to
  the empty branch rather than drawing an empty court with live controls.
- The playhead is an index into swings, never a wall clock. It is derived on
  every render (rounded and clamped into range), so a shorter payload, a
  fractional value or an out-of-range controlled value all resolve to a frame
  that exists and frame N always draws the same picture — screenshot-stable
  and SSR-safe. Controlled and uncontrolled both work: without `index` the
  component keeps its own state; with it, every move goes out through
  onIndexChange and the parent decides.
- The clock is volleyball's own — no game clock. The read-out is
  "Set 2 · 11-9" with "rotation 3 · swing 26 of 42" beside it. A set number
  outside 1-5, a non-integer or negative score, or a rotation outside 1-6 is
  not invented: the clock falls back to "Set —" / "—" and the card says so in
  a status line.
- The transport is real UI: restart, step back, play/pause, step forward, a
  scrub rail (role="slider", pointer capture, Arrow/Page/Home/End) and a
  0.5x/1x/2x speed radiogroup. Playback is one window.setTimeout per frame,
  owned by one effect that clears it before scheduling the next — pausing,
  scrubbing, changing speed and unmounting all cancel it, and a
  visibilitychange listener pauses when the tab hides. Reaching the last
  swing is derived (playing && !atEnd), never a setState from an effect; at
  the end the play key becomes a replay key and says so.
- No transport key ever uses the native disabled attribute: unavailable keys
  carry aria-disabled with a guarded handler, so nothing vanishes from under
  a finger and focus is never dropped onto the body mid-press.
- As the playhead advances, each lane's ribbon thickens with the swings it
  has taken and its landing disc grows with its kills. The scale is fixed to
  the finished match, so lanes only ever grow — never rescale under the
  reader. A lane nobody has set yet is still drawn, as a dashed corridor, and
  its row reads "not set yet".
- Keyboard: the drawing itself is one static role="img" with a one-sentence
  aria-label; the lane list underneath is the reachable equivalent —
  role="listbox" with roving tabindex, Arrow/Home/End to walk and select,
  Escape to clear the highlight. Selecting or hovering a lane dims the rest;
  the current swing never dims anything, because a highlight that moved every
  frame would strobe. A polite live region announces each frame as one
  sentence (swing n of m, set, score, rotation, lane, attacker, outcome, and
  that lane's running kill count), and a sr-only table repeats every swing.
- Edge cases are stated, never swallowed: anchors outside the court are
  clamped onto their half and counted in a visible note; swings naming a lane
  that is not on the menu are counted and excluded from every figure; a lane
  name too long for the drawing is elided with the full string in a <title>;
  a launch and a landing on the same point still produce a closed polygon
  instead of NaN.

Rendering & styling
- Court geometry, in exported pure functions: projectMetres maps metres to
  user units (x20 plus 18 units of padding), so an 18x9 m court becomes a
  360x180 drawing in a 396x216 viewBox, drawn on its side so a card can hold
  both halves. Court lines are the real ones: end lines, sidelines, the net
  on the centre line with an antenna at each sideline, the 3 m attack line on
  each side, and faint zone numerals 1-6 in the attacking half.
- A lane is three marks. The set is a dashed straight thread from the
  setter's position to the take-off point. The swing is laneRibbonPath: a
  quadratic corridor sampled 24 times along both offset edges, full width at
  take-off and tapering to 40% at the contact point, so it reads as a
  direction without an arrowhead colliding with anything. The control point
  bows away from the court's long axis, so the angle and the line out of the
  same pin fan apart instead of stacking.
- The landing disc is scaled by area (sqrt of kills over the match's best
  lane), so twice the kills is twice the ink. Its kill count is printed
  beside it in foreground ink over a card-coloured stroke halo
  (paint-order="stroke"), which stays legible over a disc, a ribbon or the
  bare floor; the label flips to the other side of the disc near the edge.
- Outcome never rides on colour alone: the ball on the current swing is a
  filled disc for a kill, a crossed circle for an attack error and a barred
  circle for a dug ball, each also named in words in the live region, in the
  lane rows and in the sr-only table.
- Tokens only — bg-card, border, bg-muted, text-muted-foreground, fill-card,
  stroke-foreground, bg-primary, ring — with lane colour from
  var(--chart-1..5) cycled by declaration order. cn() merges className.
  Every tween is gated with motion-safe (the ball's glide between contact
  points, the dimming, the rail fill, the skeleton pulse); with motion off
  playback still works, marks simply appear at each step.

Customization levers
- Court dimensions: the constants are FIVB metres — beach (16x8, no attack
  line) is three constants away, and every line, ribbon and disc re-projects
  through projectMetres.
- Lane menu: names, attackers and both anchors are data. Add a "tandem" or
  "back C", or split one hitter's line and angle into two lanes, without
  touching the drawing.
- Thickness channel: weight="attempts" (who gets set) or "kills" (who
  scores); or feed laneRibbonWidth a different number entirely — points won,
  or blocks faced.
- Ribbon shape: LANE_BOW (how far corridors fan apart), LANE_TAPER (how much
  they narrow), LANE_SAMPLES (smoothness), MIN/MAX_RIBBON and MIN/MAX_DISC.
- Playback feel: swingMs and the SPEEDS array; drop the speed radiogroup
  entirely for a compact card, or set autoPlay for a kiosk view.
- Density: the outcome tally strip in each lane row is one component
  (MAX_GLYPHS caps it); the header stats, the first-ball chip and the caption
  under the court are each one small block to keep or cut.

Concepts

  • Set-to-terminal-contact lane — one attack is drawn as two marks: a dashed thread from the setter's hands to the take-off point, then a tapered corridor across the net to where the ball came down. The taper carries direction, so the picture never needs an arrowhead sitting on top of the landing disc.
  • Derived playhead — the frame is an index into the swings array, rounded and clamped on every render rather than stored as truth. A paused card at frame N is byte-identical every time, which is what makes it screenshot-stable, SSR-safe and equally happy controlled or uncontrolled.
  • Scale fixed to the finished match — ribbon thickness and disc size are normalised against the whole match, not against the swings played so far, so a lane grows monotonically during playback instead of every lane rescaling under the reader at each step.
  • Volleyball's own clock — set, score and rotation, with the rotation turning only on a side-out, because that is the rule. Anything unreadable in the payload reads as "Set —" rather than being invented, and the card says so out loud.
  • Inspection dims, playback does not — hovering or selecting a lane dims the others; the current swing only places its ball and highlights its row. A highlight that moved on every frame would strobe the whole card during playback.
  • Reachable marks, not a legend — the drawing is one role="img" with a single-sentence label; the lane list below it is the keyboard equivalent (listbox, roving tabindex, Escape to clear), and every outcome is named in words as well as drawn as a shape.

On This Page