Basketball Lineup Stints
A four-state basketball rotation chart — one lane per player across the game clock, every shift drawn from check-in to check-out and marked by its plus-minus, with a play/step/scrub transport whose playhead reads out the live five in quarter-and-clock time.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-basketball-lineup-stints.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBasketballLineupStints" card with zod
and lucide-react (Play, Pause, RotateCcw, SkipBack, SkipForward, ChevronLeft,
ChevronRight). No charting library: a lane of shifts is a row of absolutely
positioned boxes at percentage offsets, and the labels have to be real text so
they can truncate with a title. All layout maths live in exported pure functions
beside the component so a test can print the same numbers the picture is made of.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
context?: string;
players: { id, name, number?, position? }[];
stints: { playerId, startSec, endSec, plusMinus }[];
periods?: number; periodSeconds?: number;
overtimePeriods?: number; overtimeSeconds?: number }
- startSec / endSec are ELAPSED SECONDS SINCE TIP-OFF, never game-clock time. The
game clock counts down inside a period and restarts at the next one, so "7:00"
happens four times a night and cannot order anything. Every label the card
prints goes back through clockAt() to become "Q3 7:00".
- plusMinus is the team's points scored minus conceded across the WHOLE shift, the
way a box score prints it.
- Everybody in `players` gets a lane, including the twelfth man who never checked
in — his lane says "did not play" and his figure column says DNP, exactly like a
box score. Refinements: player ids are unique; `ready` needs at least one player.
- Props = z.infer of the schema, plus the transport triple (frame?, defaultFrame?,
onFrameChange?), frameMs?, reveal?: "progressive" | "complete", onRetry?,
className and the native div props, forwardRef'd onto the card.
Behavior
- Geometry. buildPeriods() turns the declared rules into [{label, startSec,
endSec}] — Q1..Q4 for four periods, H1/H2 for two, then OT / 2OT / 3OT for each
DECLARED overtime. Overtime is never inferred from the data: a shift whose
timestamp runs past the buzzer is bad data, and growing the game to fit it would
silently rescale every bar on the card. gameSeconds() is the last period's end;
every bar is left = startSec / gameSeconds, width = (endSec - startSec) /
gameSeconds, in percent, with a 3px min-width so an eight-second cameo stays
visible and clickable.
- packLanes(players, stints, gameSeconds) is the whole data pipeline and returns
four counters with the lanes: rows naming a player who is not on the roster,
rows with a non-finite or zero-length span, rows CLAMPED into the game, and rows
TRIMMED because they overlapped an earlier shift for the same player (two
overlapping shifts would draw on top of each other and double-count his
minutes). Every counter that is non-zero is printed on the card in words.
Nothing is ever dropped in silence.
- The clock model. changeFrames() collects tip-off, the buzzer, every period start
and every check-in and check-out into one sorted array of stops. The playhead is
an INDEX into that array, because nothing changes between two stops — a
finer scrubber would only offer positions with nothing on them. The transport
therefore reads "change 8 of 13" and the scoreboard reads "Q3 7:00".
- The playhead is injected and derived, never a wall clock: frame / defaultFrame /
onFrameChange is a full controlled-uncontrolled triple, the index is clamped
against the CURRENT frame count on every render (a shorter feed must not leave
the card pointing at a stop that no longer exists), and a paused chart at frame
N renders byte-identical every time — which is what makes it screenshot-stable
and safe to render on the server.
- Transport: real buttons — jump to tip-off, previous change, play/pause, next
change, jump to the buzzer — plus an <input type="range"> over the frame index.
Never a gesture-only scrubber, and never an autoplay you cannot stop; playback
starts paused. Playback is a self-rescheduling setTimeout, one timer owned by
one effect and cleared in that effect's cleanup, so pausing, scrubbing, a new
feed and unmount all cancel it. A visibilitychange listener pauses when the tab
hides — a replay running off-screen burns frames nobody is watching. Reaching
the buzzer is DERIVED (running = playing && !atEnd), not a setState inside an
effect, and at the end the play button turns into a replay button and says so.
- Never the native `disabled` attribute on a transport button: the browser blurs a
control it disables, so the step button that runs out at the last frame would
drop the caret on <body> under the reader's finger. Use aria-disabled plus a
guard as the first line of the handler.
- What the playhead actually does, in `progressive` (the default): a shift ahead of
the playhead is a dashed outline; a shift in progress has a strong border and
fills to the playhead; a shift that has ENDED takes its plus-minus treatment and
its direct label. Minutes and plus-minus in the right column follow the same
rule — a shift pays out when it ends, because the contract carries one figure for
the whole shift and prorating it would put points on the board that were never
scored. `reveal="complete"` draws the whole rotation and shows final figures,
which is the printed rotation chart; the playhead is then only a read head.
- The live five: onFloorAt() treats a shift as half-open [start, end) — the moment
a player checks out he is off — EXCEPT at the final buzzer, where a closed
interval keeps the closing five on the floor instead of emptying the court on
the last frame of the replay. The five are listed as chips above the lanes, their
lanes are banded, and each carries a filled dot so the state is not only a wash.
- Keyboard. The lanes are one roving-tabindex group of buttons: Left/Right walk
every shift in roster order, Up/Down jump to the nearest shift in time in the
lane above or below (skipping players who never checked in), Home/End go to the
first and last shift, and Enter or Space sends the playhead to that shift's
check-in. The range input keeps its native arrows and Home/End, and PageUp /
PageDown are intercepted to mean the next and previous PERIOD, which is how a
coach reads a rotation chart.
- ARIA. The period axis is role="img" with a one-sentence label of the game's
shape. The lane group carries a one-sentence description of the whole figure.
Every bar is a button with a full sentence — player, shift number, clock range,
length, plus-minus — and aria-current on the active one. The transport announces
frame changes through a polite live region that names the clock, who came on and
who went off. A visually hidden table repeats every shift; the sr-only wrapper
must be a div, because a bare sr-only table keeps auto table layout, ignores
width:1px and drags the page into sideways scroll.
- Four first-class branches. loading is a skeleton with the same anatomy (axis,
lanes, transport) so the card does not jump when the feed lands; empty draws
empty lanes and a tip-off line; error is a message plus an optional retry, and
the button only exists when onRetry is passed; ready is everything above. A
`ready` payload with no players falls through to empty rather than rendering a
blank card.
Rendering & styling
- Semantic tokens only: bg-card panel, bg-muted lane bands and skeletons,
text-muted-foreground for captions, border for the period gridlines, bg-foreground
for the playhead. Shift colour is var(--chart-1) for a shift the team won and
var(--chart-3) for one it lost — a blue/amber pair that survives dichromacy —
mixed with var(--card) through color-mix so the figure inside stays readable in
both themes. No hex, rgb or oklch anywhere.
- Colour is never the only channel: a losing shift is also HATCHED, an even shift
is a plain muted box, and every bar wide enough carries its own +7 / −4 as text.
The rest carry the same figure in the readout line, the row total and the table.
- Motion: the only tweens are the playhead's `left` and each fill's `width`, both
behind motion-safe:. With reduced motion the replay still plays, scrubs, steps
and reads correctly — marks simply appear at each step instead of travelling.
Nothing on the card needs an in-flight animation to become readable.
- Row anatomy at every width: on-floor dot, jersey + name (truncate with a title,
never overflow), the track, then minutes and plus-minus. The name and figure
columns are fixed width so every lane shares one origin and one scale.
tabular-nums on every figure so the columns do not jitter during playback.
- cn() merges className, the card spreads the remaining native div props and
forwards its ref.
Customization levers
- Lane pitch: LANE_H (26px) is the only vertical geometry constant — lane box,
bar inset and skeleton all derive from it. Drop it to 22 for a fifteen-man
roster, raise it to 32 for a broadcast card.
- Clock: periods / periodSeconds / overtimePeriods / overtimeSeconds cover NBA
(4 x 720), FIBA (4 x 600), NCAA halves (2 x 1200) and any overtime. The period
labels are one line in buildPeriods().
- Playback: frameMs (clamped 200-5000) is one lineup change; add a speed
radiogroup around it if the game is long. Autoplay is deliberately absent — if a
hero card must run, drive `frame` from an intersection observer and keep the
pause button.
- Density: drop the axis strip, the legend, the readout line or the on-floor chips
independently; each is a sibling block, and the lanes stand alone.
- Encoding: swap segmentStyle() to colour by position group, by unit, or by
points-per-minute instead of plus-minus — keep the second non-colour channel
(pattern or text) whatever you choose.
- Reveal: `progressive` for the replay, `complete` for the printed chart. A third
mode that hides future shifts entirely is a one-line change in the bar branch.
- Ordering: lanes render in roster order. Sort by minutes, by starters first, or by
plus-minus before passing `players` — the component keys off ids, so the keyboard
walk and the roving tabindex follow whatever order you hand it.Concepts
- Elapsed seconds in, game clock out — the contract stores time as seconds since tip-off, because a game clock counts down and restarts every period, so "7:00" is four different moments in one night. Every label on the card is derived back through one
clockAt(), which is also what makes overtime a labelling problem rather than a data problem. - The playhead is an index into lineup changes — nothing about this chart changes between two substitutions, so the scrubber's stops are exactly tip-off, the buzzer, the period starts and every check-in and check-out. That makes each step meaningful ("change 8 of 13"), the transport finite, and a paused frame reproducible byte for byte.
- Injected instant, controlled or not — playback never reads a wall clock. The component takes
frame/defaultFrame/onFrameChange, clamps the index against the current frame count on every render, and derives everything else, so a shorter feed cannot leave it pointing at a stop that no longer exists and a screenshot at frame 7 is the same picture every time. - A shift pays out when it ends — plus-minus belongs to the whole shift, so a bar in progress shows only its minutes, and the figure column carries the last completed shift with an ellipsis. Spreading one number across the minutes would draw points that were never scored.
- Trim overlaps, clamp strays, count both — two overlapping shifts for one player are cut back rather than stacked (they would double-count his minutes), a shift running past the buzzer is clipped to it, and unusable rows are left out — with all four counts printed on the card. A rotation chart that quietly dropped rows would misstate every minute above it.
- Reduced motion keeps the replay — the only tweens are the playhead's travel and each fill's growth, both behind
motion-safe:. With motion off the shifts simply appear at each step: playback, scrubbing, the live five and every figure are unchanged, because reduced motion means no journey, not no destination.
Basketball Momentum Run
A four-state basketball momentum replay — the score margin as a stepped mountain over the game clock, unanswered runs called out as banded spans, timeouts and lead changes marked, driven by a real play / pause / step / scrub transport.
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.