Charts

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.

Preview in your theme

Loading preview…

"use client"

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

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartBasketballShotZones" card in plain
SVG with zod and lucide-react. Recharts has no half court in it, so the floor,
the zone polygons, the label fitting and the transport are all hand-rolled, in
small pure functions exported beside the component so a test can print the same
numbers the picture is made of.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    subject?: string;
    shots: { period: number (int 1-12); clock: string; x: number; y: number;
             made: boolean; shooter?: string; action?: string }[];
    baseline?: { zone: Zone; pointsPerShot: number }[] }
  with Zone = "restricted" | "paint" | "mid-range" | "left-corner-3" |
  "right-corner-3" | "above-the-break-3".
- A shot carries NO point value. On a basketball court the value of an attempt
  is a property of the spot it was taken from, so the zone decides it; a feed
  that shipped its own 2/3 flag could disagree with the floor, and then the
  points per shot printed inside a zone would not be the points per shot of
  that zone. Free throws are not attempts from the floor and are not in this
  array at all.
- x and y are deliberately UNBOUNDED in the schema (feet from the left sideline
  and from the baseline, hoop centre at 25, 5.25). Tracking data really does log
  attempts a foot out of bounds and heaves from past half court, and those are
  clamped onto the floor and counted out loud rather than rejected at the door.
- clock is a STRING, because that is what a play-by-play feed ships and because
  an unreadable clock has to survive as a stated absence, not as a silent zero.
- superRefine rejects two baseline rows for one zone: the chart would otherwise
  silently pick one and grade a zone against a bar nobody chose.
- Props = z.infer of the schema plus the playhead triple frame / defaultFrame /
  onFrameChange(frame, clock), the play triple playing / defaultPlaying /
  onPlayingChange, accumulate: "game" | "period" (default "game"), frameMs
  (default 700, clamped 80-5000), sampleFloor (default 4), onZoneSelect,
  onRetry, className and the rest of the div's native props. forwardRef to the
  card element.
- Export the maths: courtToView(), clampToCourt(), zoneOfShot(), zoneValue(),
  zonePath(), zoneLabelBox(), labelFits(), parseGameClock(), formatGameClock(),
  periodLabel(), periodSeconds(), elapsedSeconds(), buildTimeline(),
  periodWindowStart(), periodJumpTarget(), summariseZones(), pointsPerShot(),
  resolveBaselines(), intensityFor(), plus DEFAULT_BASELINE_PPS.

Behavior
- GEOMETRY IS THE RULE BOOK. 50 x 47 ft of half court drawn at 10 units per
  foot inside a 520 x 490 viewBox with 10 units of padding, so a boundary line
  is never half-clipped. Hoop centre (25, 5.25), rim radius 0.75, backboard at
  y = 4, key 16 ft wide and 19 ft deep, free-throw circle radius 6, restricted
  arc 4, three-point arc 23.75 and the corner lines 22 ft off the hoop's axis
  (x = 3 and x = 47). The corner lines meet the arc at
  y = 5.25 + sqrt(23.75^2 - 22^2) ~= 14.2 ft; that break is where the straight
  line becomes the arc, and every zone edge is derived from it.
- ZONING FOLLOWS THAT ORDER, and the order is not cosmetic: below the break a
  shot outside |x - 25| >= 22 is a corner three even at 22.5 ft from the hoop,
  and only above the break does distance >= 23.75 decide. Inside the arc, the
  restricted area is the 4 ft arc closed off by the two lines dropping to the
  baseline — written as |dx| <= 4 && (dy <= 0 || distance <= 4) so the test
  matches the drawn polygon exactly; then the 16 x 19 key is the paint, and
  whatever is left is mid-range. The six zones are exhaustive: the attempt
  counts always add up to the total, so no "other" bucket exists to hide a bug.
- ZONE POLYGONS are traced as one closed path each rather than as a shape with
  a hole, so no fill-rule is needed and the pointer target is exactly the
  painted region: the paint runs down its right edge, along the baseline and
  back up around the restricted arc; mid-range runs up the left corner line,
  along the three-point arc, down the right corner line and carves the key out
  along the baseline. Arcs bowing away from the baseline run left-to-right with
  sweep-flag 0 and right-to-left with sweep-flag 1.
- THE CLOCK IS THE SPORT'S, not a wall clock. Periods 1-4 are quarters of
  12:00, 5 and up are overtimes of 5:00 labelled OT, 2OT, 3OT. parseGameClock
  accepts "07:41", "7:41", tenths near the buzzer ("0:04.3") and a bare seconds
  count; anything else is null and stays null. elapsedSeconds() turns period +
  remaining into seconds since tip-off — a SORT KEY, never a printed figure —
  and an attempt whose clock could not be read sorts to the end of its OWN
  period rather than to the top of the game, so one broken timestamp moves one
  shot instead of rewriting the night.
- THE PLAYHEAD IS DERIVED, NEVER A WALL CLOCK. buildTimeline() sorts a copy of
  the feed and hands back frames: frame 0 is tip-off, frame N holds the first N
  attempts. The rendered frame is clamp(round(frame prop ?? own state), 0,
  shots.length) computed during render, so a feed that reloads shorter cannot
  leave the playhead past the end, and a paused card at frame N renders
  byte-identical every time — which is what makes it screenshot-stable and
  SSR-safe. Uncontrolled state starts at MAX_SAFE_INTEGER, i.e. "the final
  buzzer", and is clamped on read.
- THE TRANSPORT IS REAL UI, not a gesture. Seven buttons — tip-off, previous
  quarter, step back, play/pause, step forward, next quarter, final buzzer —
  plus 0.5x/1x/2x speed and a scrub rail with a tick and a label at each
  quarter's first attempt. At the buzzer the play button becomes a REPLAY
  button and says so, so the primary control never dead-ends. Playback is one
  window.setTimeout owned by one effect, keyed on the frame: pausing,
  scrubbing, changing speed and unmounting all clear it before anything else is
  scheduled. Reaching the end is derived (running = playing && !atEnd), never a
  setState fired from an effect. A visibilitychange listener pauses when the tab
  hides, because a throttled timer coming back half a quarter adrift is worse
  than coming back paused; the listener is removed with the effect.
- NEVER NATIVE disabled ON A FOCUSABLE CONTROL. Every transport button stays in
  the DOM and in the tab order, goes aria-disabled at the ends of the timeline,
  and guards inside its own handler. A step-forward button that vanishes under
  the finger at the last frame drops focus onto <body>, and the keyboard has to
  start again from the top of the card.
- WINDOW. accumulate="game" totals from tip-off to the playhead;
  accumulate="period" starts each quarter from zero, so the zones read as this
  quarter only. Same geometry, same transport, same clock — one prop.
- EFFICIENCY, NOT VOLUME. Each zone's points per shot is makes x zone value /
  attempts, compared against a league bar (DEFAULT_BASELINE_PPS: 1.32 at the
  rim, 0.86 in the paint, 0.82 mid-range, 1.17 in the corners, 1.05 above the
  break), overridable per zone through the contract. The verdict is above,
  level (within +/-0.02, a rounding difference), below, thin (fewer than
  sampleFloor attempts, graded by nobody) or none. The header carries points
  per shot, FG and eFG% = (FGM + 0.5 x 3PM) / FGA — the three quantities a fan
  reads first.
- KEYBOARD. The zone chips under the court are a role="toolbar" with a roving
  tab stop:
    Tab               into the group, once
    Left / Up         previous zone      Right / Down   next zone
    Home / End        first / last zone
    Enter / Space     onZoneSelect(zone) when it is given
  and the rail is a role="slider":
    Left / Down  -1 attempt     Right / Up  +1 attempt
    PageDown / PageUp  previous / next quarter
    Home / End         tip-off / final buzzer
  Both preventDefault only for keys they handled, so Tab and the browser's own
  shortcuts survive.
- DEGENERATE DATA is the test that matters. Zero attempts (or zero placeable
  ones) renders the empty branch with the notes still printed. One attempt
  works, and every zone it did not touch says "0 att" rather than 0.00. A
  coordinate that is not a number cannot be placed, so it is counted and named.
  A coordinate off the floor is clamped and counted — never dropped, because a
  chart that quietly loses attempts lies about the percentage too. An
  unreadable clock keeps its place and prints "--:--". A label too wide for its
  zone falls back to the bare number and then disappears entirely, leaving the
  value in the tooltip, the chip, the readout and the table.
- CLEANUP: one timeout, one visibilitychange listener, one pointer capture on
  the rail — all released on pause, on unmount and on pointercancel /
  lostpointercapture. No rAF, no ResizeObserver, no window listeners left
  behind; responsiveness is viewBox plus one max-width.

Rendering & styling
- SEMANTIC TOKENS ONLY. Floor lines in stroke-border, rim and backboard in
  stroke-muted-foreground, card in bg-card, and exactly two chart tokens:
  var(--chart-1) for a zone above its bar and var(--chart-4) for one below.
  The ramp is opacity, not new hues — intensityFor() maps the gap to 0.18-0.80
  and saturates at 0.45 points per shot, about as far as a real zone strays
  over a game. No hex, no rgb, no invented colour anywhere, court markings
  included.
- COLOUR IS NEVER ALONE, four ways. Above the bar is a SOLID fill; below it is
  a 45-degree HATCH; a thin sample is a DOT texture; an untouched zone is
  outline only. Every zone also prints its points per shot and a glyph in the
  picture, its makes/attempts underneath, and repeats all of it in the chip row
  and the screen-reader table — so the reading survives greyscale, a bad
  projector and a red-green reader.
- The attempts themselves are drawn in neutral ink (filled = made, hollow =
  missed) so the colour channel stays reserved for efficiency, and one ring
  travels to the newest attempt: a single element whose transform tweens under
  motion-safe, so playback reads as a move rather than a flash. Every tween —
  the ring, the zone fill opacity, the rail fill and the handle — is gated
  motion-safe; with motion off, marks simply appear at each step and PLAYBACK
  STILL WORKS.
- LABEL FITTING. Each zone declares its own anchor, rotation and font sizes
  (the 3 ft corner strips take their labels rotated 90 degrees up the strip).
  labelFits() estimates the advance at 0.6 em per character and steps down:
  "1.44 up-triangle" -> "1.44" -> nothing. Every glyph gets a --card outline
  through paint-order: stroke, so a number stays legible over a dense hatch.
- ACCESSIBILITY. The court is a single role="img" with a one-sentence
  aria-label naming the instant, the totals, the best and worst zones and what
  the tint means — a picture that changes with the playhead should say what it
  shows, not enumerate coordinates. The reachable marks are the chips, not the
  polygons: real buttons carrying the same numbers, so a legend never
  substitutes for a reachable label. A polite live region announces the clock,
  the running totals and the last attempt on every frame change. Below, an
  sr-only WRAPPER DIV holds a real table of the six zones at this instant — put
  sr-only on the wrapper, never on the table, because CSS width is only a lower
  bound for a table box and width:1px does not hold one back.
- The visible readout line under the court follows hover or chip focus and is
  aria-hidden on purpose: the focused chip already announces the same sentence,
  and a live region would say all of it twice.

Customization levers
- accumulate: "game" for the night so far, "period" for a quarter-by-quarter
  board. The single most useful switch on a broadcast panel.
- baseline: re-point the bar. League average is the default story; pass the
  opponent's defensive numbers to grade shot selection against this matchup, or
  the player's own season to show a hot night. Change the word "league bar" in
  the readout and the table when you do.
- sampleFloor: 4 for one game, 15-25 for a season aggregate. Below it a zone is
  described, not graded — raise it and small samples stop shouting.
- frameMs and the SPEEDS array: 700 ms an attempt reads like a highlight reel,
  250 ms like a fast-forward. Drop the speed control entirely for a card that
  is only ever scrubbed.
- Frames are attempts here because that is the finest honest step in a shot
  feed; swap buildTimeline's frame unit for possessions or clock ticks and the
  rest of the transport is unchanged.
- Palette: the two chart tokens can become any pair from the ramp; keep the
  hatch and dot textures whatever you choose, or the encoding collapses to
  colour alone. Widen intensityFor's saturation point to make gaps louder.
- Sub-blocks that can go without touching the geometry: the eFG% figure, the
  legend row, the quarter labels under the rail, the readout line. The chips
  cannot — they are the keyboard path to the data.

Concepts

  • Derived playhead — the chart takes a frame index, never a timestamp of its own. Every figure on the card is a pure function of the contract plus that integer, so pausing at frame 38 gives the same pixels on every load, on the server and in a screenshot; playback is just something that increments the integer on a timer.
  • The corner rule before the arc — a shot from 22.5 ft in the corner is a three and a shot from 23 ft at the top of the key is not, because below the break the line is straight. Testing distance first is the classic shot-chart bug: it quietly turns corner threes into long twos and drags the mid-range number down with them.
  • Points per shot against a bar — 1.02 is excellent from mid-range and poor at the rim, so a raw efficiency number means nothing until it is compared with what that zone usually returns. The gap, not the value, drives the tint; the value is still printed, because a reader who disagrees with your bar deserves the number.
  • Sample floor — four attempts at 50% is not a 1.00 PPS zone, it is four attempts. Below the floor a zone is drawn in a neutral dot texture and described rather than graded, which keeps a hot two-for-two corner from painting the loudest cell on the floor.
  • Counted, never dropped — attempts logged off the floor are clamped onto the nearest legal spot and named in a note; attempts with no usable coordinates are named too. A chart that silently discards rows also lies about the percentage it prints, and nobody can see which one it did.
  • A transport that cannot vanish — every button stays mounted and goes aria-disabled at the ends, and the play button turns into replay at the buzzer. Controls that disappear under the finger, or go native-disabled while focused, throw focus back to the document body mid-interaction.

On This Page