Soccer xG Race
A four-state expected-goals race replayed on the match clock — cumulative xG staircases that grow only as far as the playhead, with real transport buttons, a running scoreline and goal chips that jump the clock.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-soccer-xg-race.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSoccerXgRace" match-replay chart in
plain hand-rolled SVG (no chart library), with zod and lucide-react.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
kickoffLabel?: string; home / away: { label; code? };
shots: { id; team: "home" | "away"; period: 1 | 2; minute: number;
stoppage?: number; xg: number; player?;
outcome: "goal" | "saved" | "off-target" | "blocked" }[];
frameMs?: number }
refined so shot ids are unique — two attempts on one id would share a React
key and a goal chip, and one of them would quietly leave the replay.
- Props are z.infer of that schema plus variant?: "race" | "difference", the
playhead triple value / defaultValue / onValueChange(frame), onRetry?, and the
root div forwards ref and spreads the remaining HTML props
(Omit "title" | "defaultValue").
- kickoffLabel arrives already formatted. The component never touches Date, so a
paused frame renders identically on the server, in the browser and in a
screenshot taken a year later.
Clock model
- Football's own units: period + minute + added time. 45+2 arrives as
{ period: 1, minute: 45, stoppage: 2 } and reads back as "45+2′"; regulation
minutes read as "63′". Nothing in the payload is a pixel.
- Every layout number comes out of an exported pure function, so a test can
print exactly what the picture is made of:
· clockLabel(period, minute, stoppage) — the read-out above.
· timelinePosition(period, minute, stoppage, firstAdded) — axis minutes. The
first half owns 0…45+firstAdded, the second half the rest, so added time
WIDENS the axis instead of overlapping what follows: 45+2 and 47 are
different moments and must never share an x.
· buildRace(shots) — order by clock (ties keep feed order), accumulate xG and
goals per side, and return the marks plus the counts of unusable and
clamped rows and the peak lead. firstAdded / secondAdded are the largest
added time each half declares, capped at 15.
· stepPath(points, x, y, end) — "M… H… V…" step-after path that stops at the
playhead. xgTicks(peak) / diffTicks(peak) — even 0.5 xG ticks until totals
pass 2.5, whole goals after; the difference view is symmetric about zero.
Behavior
- Four first-class branches inside one bg-card panel: loading = a skeleton in
the ready layout (scoreline, staircase silhouette, transport row); empty =
dashed staircase + zero-attempt copy, which a "ready" payload with no usable
attempt also lands in; error = message plus a "Try again" button only when
onRetry is passed; ready = the replay.
- Frames: 0 is kick-off, n is the moment just after the nth attempt. The
playhead is DERIVED on every render — clamp(round(value ?? internal), 0,
attempts) — never stored, so a shorter feed or a wild controlled value cannot
leave it pointing past the end. Controlled and uncontrolled both work;
onValueChange fires for playback ticks, steps, scrubs and goal jumps, and a
controlled owner that ignores it simply holds the replay still.
- The transport is real UI, never a gesture-only scrubber: step back,
play / pause, step forward, a native range scrubber and an "n / total"
counter. At full time the play button becomes replay (rewind to kick-off and
run). Stepping, scrubbing and jumping all pause first.
- Playback is one window.setTimeout per frame owned by one effect: pausing,
scrubbing, every re-render and unmounting clear it before another is
scheduled, and a visibilitychange listener pauses when the tab hides — no
loop is ever left running off-screen. Reaching the end is derived
(playing && !atEnd), never a setState inside an effect.
- Ready draws only what has happened: both step lines stop at the playhead,
only played attempts get marks, and the header carries the running scoreline
and running xG to 2 dp at that moment. Both scales are fixed to the whole
match, so scrubbing never rescales the picture under the reader.
- Keyboard: the range scrubber is one tab stop and inherits the native map
(← / → one attempt, Home / End kick-off / full time, PageUp / PageDown by
a tenth of the match). The goal chips are a roving-tabindex toolbar — ← / → move focus,
Home / End reach the first and last goal, Enter or Space moves the playhead
there. Jumping to a goal always lands on the frame that contains it, so the
chip under the focus ring can never unmount from beneath it.
- Edge cases are visible, never silent: attempts with an unreadable minute or
xG are left out of the drawing and counted; a minute outside its own half,
added time past 15 and an xG outside 0–1 are clamped and counted; both counts
print in a footnote under the chart. A 0.00 xG attempt still draws its mark on
a step of no height, a side with no attempts keeps a flat 0.00 line, long club
and player names truncate with a title attribute, and goal minute labels flip
their anchor near the edges because the SVG root clips in silence.
Rendering & styling
- Semantic tokens only: home = var(--chart-1), away = var(--chart-2), panel
bg-card, grid stroke-border, added-time bands fill-muted, axis text
fill-muted-foreground, playhead stroke-foreground/50, scrubber accent-primary.
cn() merges the consumer's className into every root.
- Colour is never the only channel: home draws solid and away dashed, and the
outcome is a shape — goal = filled dot inside a ring, saved = hollow circle,
blocked = square, off target = cross — repeated in a legend with live counts,
with every goal also labelled by its minute on the plot.
- variant="difference" draws one home-minus-away step area around a zero rule,
clipped into two halves so the fill takes the colour of whoever leads on
chances, and both halves of the axis are labelled "<team> ahead".
- Motion: only the playhead tweens, and only under motion-safe. With motion off
the marks simply appear and disappear at each step and nothing depends on an
in-flight animation to become readable.
- Accessibility: the svg is role="img" with a one-sentence aria-label (teams,
clock, running score, running xG); every frame change is announced in a
polite live region; the scrubber carries aria-valuetext with clock, score and
xG; transport buttons at the ends take aria-disabled plus a handler guard,
never native disabled, so a control never vanishes from under a finger; an
sr-only table lists every attempt with clock, team, player, xG, outcome,
running xG and running score.
Customization levers
- Palette: TEAM_COLOR maps home / away onto two chart tokens and TEAM_DASH gives
each side its line style. Re-map to club colours by swapping which token each
side wears — header swatches, lines, marks and difference fills all read from
the same map.
- Density: HEIGHT, PAD_*, MIN_WIDTH and the 15-minute tick interval. The legend,
the footnote and the goal chips are safe to drop for a thumbnail embed; the
sr-only table should stay, it is the non-visual story.
- Pace: frameMs in the contract, clamped to 150–5000. Add a speed control by
dividing it, or drive the frame from your own clock through the controlled
value and leave playback out entirely.
- Format: HALF and STOPPAGE_CAP describe two 45-minute halves. For extra time,
add periods 3 and 4 to the enum and extend clockLabel and timelinePosition —
every other coordinate is derived from those two functions.
- Vocabulary: OUTCOME_LABEL and OutcomeGlyph are the whole outcome language;
adding "hit the post" or "own goal" is one enum member, one label and one
shape.Concepts
- Derived playhead — the frame index is recomputed from
value ?? internalon every render and clamped to the attempts that actually exist, so playback state is never a stale number: a feed that shrinks, or a controlled owner that hands over nonsense, both land on a legal frame instead of an empty picture. - Sport-true clock — the chart speaks minutes and added time, not array indices; added time widens the axis so
45+2′sits before46′rather than on top of47′, and the half-time rule falls where the whistle actually went. - Step-after honesty — each attempt lifts its side's line by exactly its own xG at the minute it happened, so a tall step with no goal shape is a chance spurned and a goal on a flat step is a finish out of nothing; nothing is smoothed into a slope that implies chances between shots.
- Transport that cannot vanish — the ends of the timeline turn buttons
aria-disabledinstead of unmounting or natively disabling them, so the control under your finger is still there at frame 0 and at full time, and focus never falls back to the body mid-replay. - Motion is an enhancement, not the mechanism — only the playhead tweens, and only under
motion-safe; with motion off the replay still plays, marks simply appear and disappear step by step. - Fixed scale, moving story — both axes are sized to the whole match rather than to what has been played, so scrubbing moves the story without resizing the picture, and a paused frame screenshots identically every time.
Soccer Pass Network
A replayable soccer pass network in hand-rolled SVG — raw completed passes cut into match windows, a disc at each player's average position sized by involvements, lines weighted by passes exchanged, and a play / pause / step / scrub transport running on a real match clock.
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.