Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Pause, Play, RotateCcw, SkipBack, StepBack, StepForward } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartBasketballMomentumData,
  ChartBasketballMomentumFormat,
  ChartBasketballMomentumPlay,
  ChartBasketballMomentumSide,
  ChartBasketballMomentumTeam,
} from "./chart-basketball-momentum.contract"

Installation

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

Prompt

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.

On This Page