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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-volleyball-attack-lanes.jsonPrompt
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.
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.
Baseball Strike Zone
A four-state strike zone from the catcher's view — pitch type by colour, outcome by shape, called-strike-chance shading, and a transport that replays the at-bat pitch by pitch with the count moving as it goes.