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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-basketball-shot-zones.jsonPrompt
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.
Soccer Shot Quality
A four-state soccer shot map that replays a match shot by shot — marks placed on the attacking half, sized by xG, shaped by outcome, driven by a real play / pause / step / scrub transport on the football clock.
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.