Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-basketball-lineup-stints.json

Prompt

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.

On This Page