Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import type {
  ChartSoccerShotQualityData,
  ChartSoccerShotQualityOutcome,
  ChartSoccerShotQualityShot,
} from "./chart-soccer-shot-quality.contract"

/** how much of the attacking end is drawn */
export type ChartSoccerShotQualityView = "attacking-half" | "final-third"

Installation

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

Prompt

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 + onFrameChange and 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.

On This Page