xG Timeline
A cumulative expected-goals step chart — two team lines over 90+ minutes, emphasized goal dots with minute labels, a half-time rule, and final xG totals in the header.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-xg-timeline.jsonPrompt
Build a React + TypeScript + Tailwind "ChartXgTimeline" match widget in plain
hand-rolled SVG (no chart library) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
home: { label }; away: { label };
events: { minute >= 0; team: "home" | "away"; xg: 0..1; isGoal }[] }.
- Component props = z.infer of the schema, plus onRetry?: () => void, and the
root div forwards ref and spreads remaining HTML props (Omit "title").
- Minutes past 90 are raw stoppage minutes (90+3 arrives as 93); the chart
formats them back to "90+3′" itself.
Behavior
- The four states are first-class branches inside one bg-card panel:
loading = pulsing header bars + a staircase silhouette (aria-hidden,
motion-reduce:animate-none); empty = dashed staircase + zero-data copy
(a "ready" payload with zero events also lands here); error = message +
a "Try again" button only when onRetry exists; ready = the chart.
- Sort events by minute, then accumulate xG per team into steps. Draw each
team as a step-after path: flat to the attempt's minute, then a vertical
jump of exactly that attempt's xG ("M.. H.. V.." segments).
- X domain is 0..max(90, last minute); regulation ticks every 15 minutes plus
a labelled "90+n" tick when the match ran long (merged into the 90 tick if
the two would collide). Y ticks step by 0.5 xG until totals pass 2.5, then
by 1.0.
- Goals get an emphasized dot (team fill, card-colored stroke) at the running
total, a native <title> tooltip, and a minute label — home above its dot,
away below, both clamped inside the canvas because the SVG root clips
silently.
- A dashed vertical rule at 45′ marks half-time, labelled "HT".
- Header row doubles as the legend: per team a color swatch, label, goal
count and the final xG total (toFixed(2), tabular-nums).
- Width is measured with a ResizeObserver via a callback ref (the frame only
exists in the ready branch), sub-pixel churn ignored, observer disconnected
on unmount; height is fixed at 240.
Rendering & styling
- Semantic tokens only: home = var(--chart-1), away = var(--chart-2), panel
bg-card, grid stroke-border, axis text fill-muted-foreground, goal-dot
stroke var(--card). cn() merges className.
- Accessibility: the svg is role="img" with a summary aria-label (goals, xG
totals, attempt counts per team); an sr-only table lists every attempt with
minute, team, xG and outcome; the retry button has a focus-visible ring.
Customization levers
- Palette: TEAM_COLOR maps home/away onto two chart tokens — re-map to club
colors by swapping which token each side wears; keep the header swatches,
dots and lines reading from the same map.
- Density: HEIGHT, PAD_* and the 15-minute tick interval. Minutes past 90
always render as stoppage time ("90+n′"); for extra-time formats swap
minuteLabel and the "90+n" stoppage tick label for period-aware formatting.
- Goal emphasis: GOAL_RADIUS and the minute-label font size; drop the labels
entirely for thumbnail embeds and lean on the <title> tooltips.
- Sub-blocks: the explanatory footnote and the header goal counts are safe to
remove; the sr-only table should stay — it is the non-visual story.
- Period dividers: the half-time rule is one line + text at minute 45;
duplicate it at 90/105 for extra-time storytelling.Concepts
- Cumulative step as narrative — accumulating each attempt's xG turns a list of shots into a match story: steep staircases are periods of pressure, long flat runs are a team going missing, and the gap between the two lines at full time is the "who deserved it" verdict.
- Step-after semantics — an attempt's xG applies from the minute it happened, so the line runs flat to the shot and jumps at it; the jump height is honest (exactly one shot's quality), never smoothed into a slope that implies chances between shots.
- Event-anchored goal markers — goals are dots pinned to the running total at their minute, so a cheap goal visibly sits on a tiny step while a missed sitter shows as a big step with no dot: the chart separates finishing from chance creation.
- Phase divider — the dashed 45′ rule cuts the story into halves without consuming a data channel; comparing staircase steepness on either side of it is the half-by-half read.
- Elastic time domain — the axis ends at max(90, last minute), so stoppage-time drama extends the pitch-time axis with a labelled "90+n" tick instead of clipping or silently rescaling regulation minutes.
- Header as legend — the swatch + label + goals + final xG row is both the color key and the headline result, so the chart never needs a separate legend block or a second place for totals to drift out of sync.
Pass Network
A four-state soccer pass network in hand-rolled SVG — players at their average positions on a pitch, marker area by touches, edges by pass count above a threshold, hover to light one player's links.
Win Probability
A four-state in-game win probability chart: one line around a 50% midline, the band shaded toward whichever side is favoured in that side's chart token, and key events pinned to the path — named on hover, focus and in the readout.