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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-basketball-momentum.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBasketballMomentum" card in
hand-rolled SVG with zod. No chart library: the mark is a step area over a
basketball game clock, the axis units are period + countdown clock, and the
whole picture is one pass over a play-by-play, so the maths lives in exported
pure functions beside the component.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
context?: string;
teams: { home: { name: string; abbr?: string };
away: { name: string; abbr?: string } };
plays: ( { kind: "basket"; period: int >= 1; clock: number >= 0;
team: "home" | "away"; points: 1 | 2 | 3; label?: string }
| { kind: "timeout"; period: int >= 1; clock: number >= 0;
team: "home" | "away"; label?: string } )[];
format?: { regulationPeriods?: 1–8; periodSeconds?: > 0;
overtimeSeconds?: > 0; periodLabel?: "quarter" | "half" };
runThreshold?: int >= 2 }.
- TIME IS THE SPORT'S OWN: `period` is 1-based (1–4 regulation, 5+ overtime)
and `clock` is the seconds LEFT in that period, counting DOWN. Tip-off is
period 1 / clock 720; the buzzer is the last period at clock 0. Nothing
carries a wall-clock timestamp, so the array order does not matter — the
clock orders it.
- Props = z.infer of the schema plus the playback triple
frame? / defaultFrame? / onFrameChange?(frame), frameMs? (default 700,
clamped 120–5000), autoPlay? (default false), onRetry?, className and the
div's native props (`title` omitted). forwardRef the card. No hand-written
parallel interface.
- Export the arithmetic so a test can print the numbers the picture is made of:
resolveMomentumFormat, momentumPeriodSeconds, momentumPeriodStart,
momentumElapsed, momentumClockLabel, buildMomentumGame, momentumLedgerAt,
momentumFrameAt, momentumX, momentumY, momentumStepPath, momentumAreaPath,
packMomentumLabels, MOMENTUM_PLOT, MOMENTUM_NBA_FORMAT.
Clock model and geometry
- momentumElapsed(period, clock) = sum of the lengths of the periods before it
+ (that period's length − clock). That single number is the x axis; the
game's length is the start of the period after the last one seen, so an
overtime widens the axis instead of overflowing it.
- x = plotLeft + clamp(elapsed / totalSeconds, 0, 1) * plotWidth inside a fixed
640 × 232 viewBox (svg is w-full h-auto, so nothing is ever measured and a
paused frame renders byte-identical at any width).
- y = zeroY − clamp(margin / maxAbs, −1, 1) * plotHeight / 2, where
margin = home − away and maxAbs is fixed by the WHOLE game, rounded up to a
multiple of 4 with a floor of 8. Deriving the scale per frame would make the
mountain rescale as it grows, which is unreadable.
- The margin is a step function — it holds until the next basket lands, then
jumps — so the outline is drawn as steps (horizontal to the basket's x at the
old y, then vertical to the new y) and never interpolated. Interpolating
would draw scores that were never on the board.
- The area path closes back onto the centre line and is drawn TWICE, once
clipped above the line and once below, so each half gets its own fill and its
own outline colour. A run band is a rect across the full plot height from the
run's first basket to its last.
Behavior
- Four first-class branches in one bg-card panel: loading (skeleton mirroring
the ready layout — ledger line, plot block, transport row — plus a sr-only
role="status"), error (message + a "Try again" button only when onRetry is
given), empty (a bare centre line and period dividers, plus copy naming which
side belongs to which team), ready. status "ready" with no usable basket
falls into the empty branch rather than drawing an axis with nothing on it.
- THE PLAYHEAD IS A FRAME INDEX, never a wall clock: frame 0 is tip-off and
frame N is "after the Nth made basket". Controlled (frame + onFrameChange)
and uncontrolled (defaultFrame) both work; the value is clamped on read, not
stored clamped, so a feed that reloads shorter cannot leave the card pointing
past the buzzer.
- A RUN is unanswered scoring: a maximal stretch of consecutive baskets by one
team. It survives a timeout and ends the moment the other team scores. A band
appears once the stretch has reached runThreshold (default 6) points AS OF
the current frame, its caption counts up ("HAR 9-0" → "HAR 11-0"), and a run
still alive keeps a dashed open right edge.
- Every headline figure is as-of the frame, never final: score, lead changes,
times tied, biggest lead each way, best run so far. Spoiling the ending in
the header would defeat the replay.
- Transport = real buttons, never a gesture-only scrubber: back to tip-off,
previous basket, play/pause (which becomes replay at the buzzer), next
basket, plus a rail that is inset to the plot's own box so a press lands on
the second of game time it points at in the picture above.
- Keyboard, on the rail (role="slider", one tab stop): ArrowLeft/Down = previous
basket, ArrowRight/Up = next, PageDown/PageUp = previous/next period, Home =
tip-off, End = the buzzer. Only handled keys call preventDefault.
- prefers-reduced-motion: playback still works with motion off. The mountain,
the bands and the marks simply appear at each step — the only tweens are the
playhead's translate and the rail's fill/thumb, and every one of them is
gated behind motion-safe.
- Cleanup: one window.setTimeout owned by one effect, cleared on pause, on
scrub, on every frame and on unmount; a visibilitychange listener pauses
playback when the tab hides and is removed with it; the rail's pointer
capture is released on pointerup/pointercancel.
Edge cases, stated out loud
- A play whose period or clock is not a finite number (or a basket with no
usable value) cannot be placed at all: it is dropped and COUNTED in a visible
role="status" note, never silently swallowed.
- A clock longer than its own period is pinned to the period edge — a play must
not leak into the next quarter — and pinned plays are counted in the same
note.
- Plays arrive in any order and are sorted by elapsed time, ties broken by
arrival order, which is how a play-by-play separates two events on 7:41.
- One basket, or a game that never leaves a tie, still draws: the scale floor
keeps a flat game flat instead of dividing by zero.
- Captions that do not fit are elided with a title attribute (card heading,
matchup, current play) rather than allowed to wrap the transport off-screen;
run captions are packed into three rows at the top of the plot so
neighbouring bands do not print on top of each other, and the two side labels
sit at the centre line where a caption can never reach them.
- Transport buttons use aria-disabled plus a handler guard, never the native
disabled attribute, so a button never stops being focusable under the finger
at the last frame and focus never lands on <body>.
Rendering & styling
- Semantic tokens only: bg-card, border, bg-muted, bg-primary, text-foreground,
text-muted-foreground, ring, and var(--chart-1) / var(--chart-2) for the two
teams. No hex, rgb, hsl or oklch anywhere, ruler included.
- The lead is never carried by hue alone: home is a 45° hatch above the centre
line, away is a dot pattern below it, each half is labelled inside the plot
("▲ HAR", "▼ RDG"), lead changes are diamonds on the centre line, timeouts
are T-shaped ticks that hang above the ruler for home and below for away, and
the legend prints the live counts.
- Accessibility: the plot is one role="img" with a sentence naming the matchup,
the encoding and the as-of score; the transport announces every frame through
a polite live region; a sr-only table repeats every play through the current
frame with clock, team, value, score and meaning.
- cn() merges className on the card root; the skeleton pulse respects
motion-reduce.
Customization levers
- Clock shape: `format` is the whole lever. NBA is the default (4 × 12:00, 5:00
overtimes); college is { regulationPeriods: 2, periodSeconds: 1200,
periodLabel: "half" }; FIBA is periodSeconds 600. Every ruler label, period
divider and elapsed value re-derives from it.
- Run sensitivity: runThreshold decides when a stretch is worth calling a run —
6 for the NBA, 8–10 for a college half, 4 for a youth game.
- Playback feel: frameMs sets the step interval, autoPlay decides whether the
card starts moving, and the frame triple lets a parent drive several cards
from one clock or persist the playhead in a URL.
- Density: the geometry constants (VIEW_W, PLOT_H, AXIS_H, PAD_*) are the only
numbers the drawing is built from — shrink PLOT_H for a strip in a
scoreboard rail, grow it for a full-width recap.
- Colour: swap var(--chart-1) / var(--chart-2) for the club's own tokens at the
pattern and outline callsites; the hatch/dot textures keep the encoding
readable in greyscale whatever the hues become.
- Sub-blocks: the ledger line, the legend and the sr-only table are each one
JSX block and can be dropped without touching the drawing; the run bands can
be turned into a season-long "best run" strip by feeding fewer plays.Concepts
- Derived playhead — the card never reads a wall clock: it takes a frame index (0 is tip-off, N is "after the Nth basket"), supports the controlled and uncontrolled triple, and clamps on read, so the same feed paused at the same frame renders identically forever and a shorter reload cannot strand the playhead past the buzzer.
- Step area, never interpolated — the score margin holds until the next basket lands and then jumps, so the mountain is drawn as steps and closed onto the centre line; a smooth curve here would draw scores that were never on the board.
- Unanswered run band — a run is a maximal stretch of consecutive baskets by one team; it survives a timeout, dies the moment the other side scores, is banded once it reaches the threshold as of the current frame, counts up while it is alive, and keeps a dashed open edge until it ends.
- As-of ledger — score, lead changes, times tied, biggest lead each way and best run are all computed through the current frame rather than for the finished game, because a header that already knows the ending defeats the replay.
- Texture, not just hue — home fills a 45° hatch above the centre line and away a dot pattern below it, each half is labelled inside the plot, lead changes are diamonds and timeouts are T-ticks that hang above or below the ruler by bench, so the encoding survives greyscale and a legend is never the only place a mark is named.
- Honest repair ledger — plays with an unreadable clock are dropped and counted in a visible note, clocks longer than their own period are pinned to the period edge and counted too, and out-of-order feeds are sorted by elapsed time with arrival order as the tiebreak.
Basketball Shot Zones
A four-state NBA half-court zone chart in hand-rolled SVG — six rule-book zones tinted by points per shot against a league bar, replayed attempt by attempt with a real quarter-and-game-clock transport.
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.