Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import type { ChartSoccerXgRaceData, ChartSoccerXgRaceShot } from "./chart-soccer-xg-race.contract"

export interface ChartSoccerXgRaceProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "title">,
    ChartSoccerXgRaceData {
  /**
   * Which picture the same replay draws:
   * - `race` — one cumulative xG staircase per team (default)

Installation

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

Prompt

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 ?? internal on 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 before 46′ rather than on top of 47′, 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-disabled instead 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.

On This Page