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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-shot-quality.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSoccerShotQuality" component in
hand-rolled SVG (no chart library), with zod and lucide-react: a soccer shot
map that replays one match shot by shot.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
team?: string; match?: string; frameMs?: number;
shots: { x: number; y: number; xg: number;
outcome: "goal" | "saved" | "blocked" | "off_target";
minute: number; stoppage?: number;
player?: string; note?: string }[] }.
- Coordinates are attacking-half metres on a 105 x 68 pitch: x runs 0-68
across from the left touchline, y runs 0-52.5 out from the goal line, goal
centre at (34, 0), penalty spot at (34, 11). Ranges are documented, not
enforced by the schema — feeds emit shots a metre off the pitch, an xG of
1.02 and the odd unreadable clock, and rejecting the whole match over one
bad row is worse than drawing the row and saying so.
- Props = z.infer of the schema plus the playhead triple
frame? / defaultFrame? / onFrameChange?(frame), view?: "attacking-half" |
"final-third", onRetry?: () => void and className; forwardRef the root div
and spread remaining props on it.
- The playhead is a frame index, never a wall clock: 0 = kick-off with an
empty pitch, N = the pitch just after the Nth shot. Controlled when `frame`
is passed (playback then reports through onFrameChange and the parent
decides), uncontrolled otherwise. A paused chart at frame N renders
byte-identically every time, which is what makes it screenshot-stable and
SSR-safe.
Behavior
- Four first-class branches inside one bg-card panel: loading (skeleton
mirroring figures + pitch block + transport, with an sr-only role="status"),
empty (the pitch drawn faint, no marks), error (message + a Try again button
only when onRetry exists), ready. status="ready" with no placeable shot
falls into the empty branch and says whether the shots were lost to bad
coordinates.
- Clock model: shots are ordered by minute * 1000 + stoppage, so 45+2 (45002)
sits after 45 and before 46 — summing added minutes onto the minute would
file a first-half stoppage shot in the middle of the second. Read-outs are
football's own: "45+2'", the period ("first half", "second half", "extra
time, first half"), and xG to two decimals everywhere.
- Transport is real buttons, never a gesture: back-to-kick-off, previous shot,
play/pause (which becomes replay at full time), next shot, plus a wrapped
row of minute chips — one per shot plus kick-off — that is the scrub. There
is no autoplay: the card opens paused at defaultFrame.
- Playback is one setTimeout per step, owned by one effect, cleared on pause,
on scrub, on unmount and whenever document.visibilityState hides the tab
(playback resumes where it left off). Reaching full time is derived
(frame >= lastFrame), never a setState from an effect.
- prefers-reduced-motion: with motion off, marks simply appear at each step
and every tween is gone; playback, transport, clock and read-outs all still
work. Every animation is gated behind motion-safe:.
- Pointer: hovering a mark makes it the active one — ring on the pitch, its
clock printed beside it, its full detail in the strip below. A stale hover
from before a step back is ignored, and the playhead's own shot takes over.
- Keyboard: the chip row is a radiogroup with a roving tab stop on the current
frame. ArrowLeft/ArrowUp step back, ArrowRight/ArrowDown step forward, Home
jumps to kick-off, End to full time; Enter/Space activates a chip directly.
Every chip stays mounted for the whole match, so focus can never be dropped
onto <body> by a step back.
- ARIA: the pitch is one role="img" with a one-sentence aria-label (instant,
shots so far, goals, xG, and how the marks encode); the transport writes
each frame change into a polite aria-live region ("Shot 7 of 14, minute 45
plus 2, first half: A. Okonjo, saved, 0.16 expected goals…"); an sr-only
table repeats every shot with clock, shooter, outcome, xG, position and
whether it has been reached yet. Boundary buttons use aria-disabled plus a
handler guard, never the native disabled attribute — a transport button that
stops taking focus at the last frame vanishes under the finger.
- Edge cases, all visible rather than silent: shots with non-finite x/y are
dropped and counted; shots from outside the pitch are clamped onto the
nearest line and counted (and the strip says "position clamped"); shots
struck from further out than the current view draws are pinned to its far
edge and counted, never left outside the viewBox where nobody can see them;
shots with an unreadable minute replay last, in feed order, and are counted;
an xG that is unreadable or outside 0-1 folds onto 0-1 and is counted in the
same note, because the headline total is summed from the folded figures and a
silent fold would report a smaller match than the feed sent; a 0 xG shot still
draws at the minimum radius; long shooter names truncate with a title
attribute instead of overflowing.
Rendering & styling
- Geometry lives in exported pure functions so a test can print the same
numbers the picture is made of: projectMetres(x, y) = PAD + metres * 8 units
(PAD 22 = wide enough to hold the goal frame and the biggest mark),
viewBoxFor(view) (0 0 588 464 for the attacking half, 0 0 588 324 for the
final third), shotRadius(xg) = 5 + 13 * sqrt(xg) so mark *area* — not radius
— tracks xG, clockKey / formatClock / spokenClock / periodLabel, plus
buildShotMarks(shots, depth) and tallyThrough(marks, frame).
- Pitch in FIFA metres: goal mouth 7.32 wide, six-yard box 18.32 x 5.5,
penalty area 40.32 x 16.5, penalty spot at 11, the D as a 9.15 arc centred on
the spot and clipped to the top of the box (its half-width is
sqrt(9.15^2 - 5.5^2) = 7.31), 1 m corner arcs, and the halfway line plus the
centre circle only when the view is deep enough to contain them.
- Outcome is shape first: filled disc with a ring = goal, hollow diamond =
saved, hollow square = blocked, cross = off target. Colour only reinforces it
(on-target marks in var(--chart-1), the rest in muted ink), so the read
survives greyscale and colour blindness; the same four glyphs appear in the
chips, the legend and the detail strip, and the active mark also carries its
clock as a direct label with a paint-order halo.
- Tokens only: bg-card, border, bg-muted, text-muted-foreground, bg-primary for
the play button, ring-foreground for the current chip, var(--chart-1) for the
series; the future chips are dashed rather than merely paler. cn() merges
className.
Customization levers
- Pitch framing: `view` crops from the attacking half to the final third; both
go through the same projection, so switching only changes the viewBox height,
which markings fit, and which shots get pinned to the far edge. A "penalty
area only" view is one more entry in viewBoxFor plus its depth.
- Mark scale: MARK_MIN_R / MARK_MAX_R set the xG ramp — shrink both for a
season of shots, raise MARK_MIN_R if 0.01 xG chances must stay clickable.
- Tempo: frameMs ships with the data (default 1100 ms, clamped 200-8000); wire
a speed control by multiplying it, or drive the frame entirely from outside
through the controlled triple and ignore the internal timer.
- Encoding: the four shapes live in one OutcomeShape function — re-map them, or
split home/away across var(--chart-1) and var(--chart-2) by passing a side
and picking the token there.
- Blocks: the figures row, the drop/clamp note, the detail strip, the legend
and the sr-only table are independent siblings; drop any of them without
touching the drawing, and the numbers still derive from the same marks.
- Scrub density: the chip row is one button per shot — for a 40-shot match,
cap it to goals plus the current shot, or swap it for a range input; keep the
roving tab stop and the aria-live announcement wherever it lands.Concepts
- Derived playhead — the picture is a pure function of one integer frame index, not of a wall clock, so a paused card at frame 7 renders identically on every load and screenshots the same way twice; playback is just something that increments the integer.
- Controlled or uncontrolled, same transport — pass
frame+onFrameChangeand the parent owns the moment (sync it to a video, a URL or a sibling chart); pass nothing and the card keeps its own. The buttons behave identically either way. - Transport, not autoplay — play / pause / step / kick-off / full time are real focusable buttons and the scrub is a row of minute chips with a roving tab stop, so there is never a moving picture the reader cannot stop, and never a scrubber that needs a mouse.
- Football clock, not seconds — shots sort on minute × 1000 + stoppage so 45+2 lands between 45 and 46, and every read-out speaks the match's own units: the minute, the added minutes, the period, and xG to two decimals.
- Shape carries the outcome — goal, saved, blocked and off target differ as disc, diamond, square and cross before they differ in colour, and the same glyphs repeat in the chips, the legend and the detail strip, so the encoding survives greyscale and never depends on the legend alone.
- Honest geometry — shots outside the pitch are pulled onto the nearest line and counted in a visible note, shots with no coordinates are dropped and counted, shots with no readable clock replay last and are counted, an xG outside 0–1 is folded onto the range and counted; the xG total is only ever summed from the marks actually drawn, and every figure it had to bend is named under the pitch.
Soccer Territory Flow
A four-state replay of which third of the pitch a match was played in, minute by minute — a stacked territory band, a field-tilt momentum lane, jump-to-event markers and a play / step / scrub transport on the match clock.
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.