Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartXgTimelineData,
  ChartXgTimelineEvent,
} from "./chart-xg-timeline.contract"

export interface ChartXgTimelineProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartXgTimelineData {
  /** Omit to hide the retry affordance in the error branch entirely. */

Installation

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

Prompt

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.

On This Page