# RaceChart

Source: https://www.pitchkitjs.com/docs/charts/race-chart

> A cumulative step chart over match minutes — the chart usually called an xG race chart, xG timeline or xG flow chart.

```tsx
import { RaceChart } from "@pitchkit/react";

// Shots from one match, in the shape a provider gives them: a minute, a
// team, an xG, and whether it went in. Nothing is pre-aggregated —
// <RaceChart> does the accumulating.
const shots = [
  { minute: 11, team: "Spain", xg: 0.068, goal: false, period: 1 },
  { minute: 12, team: "Spain", xg: 0.118, goal: false, period: 1 },
  { minute: 16, team: "England", xg: 0.049, goal: false, period: 1 },
  { minute: 27, team: "Spain", xg: 0.048, goal: false, period: 1 },
  { minute: 42, team: "Spain", xg: 0.078, goal: false, period: 1 },
  { minute: 45, team: "England", xg: 0.18, goal: false, period: 1 },
  { minute: 46, team: "Spain", xg: 0.113, goal: true, period: 2 },
  { minute: 48, team: "Spain", xg: 0.246, goal: false, period: 2 },
  { minute: 55, team: "Spain", xg: 0.243, goal: false, period: 2 },
  { minute: 63, team: "England", xg: 0.056, goal: false, period: 2 },
  { minute: 66, team: "Spain", xg: 0.162, goal: false, period: 2 },
  { minute: 70, team: "England", xg: 0.075, goal: false, period: 2 },
  { minute: 72, team: "England", xg: 0.038, goal: true, period: 2 },
  { minute: 81, team: "Spain", xg: 0.164, goal: false, period: 2 },
  { minute: 86, team: "Spain", xg: 0.283, goal: true, period: 2 },
  { minute: 89, team: "England", xg: 0.117, goal: false, period: 2 },
];

/** One step line per team: flat between shots, jumping at each one by its xG. */
export function RaceChartBasic() {
  return (
    <RaceChart
      series={[
        { id: "Spain", data: shots.filter((s) => s.team === "Spain") },
        { id: "England", data: shots.filter((s) => s.team === "England") },
      ]}
      time={(s) => s.minute}
      value={(s) => s.xg}
      emphasise={(s) => s.goal}
      period={(s) => s.period}
    />
  );
}
```

`<RaceChart>` accumulates a value against the clock and draws one step line per series. With xG per
shot, that is the chart variously called an **xG race chart**, **xG timeline** or **xG flow chart**
— they all describe this. It is named generically because the quantity need not be xG: point it at
shot counts, expected threat, or anything else that adds up over a match.

It is a root, not a layer. There is no `<Pitch>` involved and no `type` prop — see
[Charts](/docs/charts) for why.

## Usage

```tsx
<RaceChart
  series={[
    { id: "Spain", data: spainShots },
    { id: "England", data: englandShots },
  ]}
  time={(s) => s.minute}
  value={(s) => s.xg}
  emphasise={(s) => s.goal}
  period={(s) => s.period}
/>
```

`series` carries the data; `time` and `value` are accessors like every other PitchKit prop. Nothing
is pre-aggregated — the running total is computed for you, so you hand it the same flat list of
shots a provider gives you.

## Why it steps

A cumulative total is flat between events and jumps at each one. Anything that slopes between points
would draw xG accruing during minutes when no shot was taken, which is not a thing that happens, so
there is no `curve` option. This is what d3 calls `curveStepAfter`.

The line is anchored at kick-off and runs to full time rather than starting at the first shot and
stopping at the last — a chart that begins at 11' and ends at 86' misreads as a shorter match.

## Goals, and anything else worth marking

`emphasise` is an accessor like any other, so it can pick out whatever matters in your data. It
draws the larger ringed marker on the events it returns `true` for:

```tsx
<RaceChart ... emphasise={isGoal} />
```

It is not a goal flag — it is "mark this one". Some things it is good for:

| Series            | `emphasise`                                   |
| ----------------- | --------------------------------------------- |
| Cumulative xG     | `isGoal` — the canonical case                 |
| Cumulative xG     | `(s) => s.xg > 0.3` to call out big chances   |
| Cumulative shots  | `isOnTarget`                                  |
| Cumulative passes | `isKeyPass` or `isAssist`                     |
| Cumulative xT     | the action that started the move that scored  |
| Any series        | one player's contributions inside a team line |

Leave it off and no event is emphasised. `appearance.markers` decides how much else is drawn:
`"emphasis"` (default) dots only the emphasised events, `"all"` dots every one, `"none"` dots
nothing.

Goals take their **team's** colour rather than `--pitch-marker-goal`. On a pitch, "goal" is a
property of a shot and a distinct colour reads well; here a goal already belongs to a team, so shape
carries "goal" and colour carries "whose" — which also means the chart never encodes meaning by
colour alone.

## Half time comes from the data

Pass `period` and the period breaks are derived rather than assumed:

```tsx
<RaceChart ... period={(s) => s.period} />
```

This matters more than it looks. &#x2A;*Halves do not end on 45.** Stoppage time is inside StatsBomb's
own `minute` numbering, so the Euro 2024 final's first half runs to 47'. A rule drawn at a fixed 45
sits in the wrong place in most matches. Without a `period` accessor no rules are drawn at all,
which is better than drawing one somewhere wrong.

The same accessor gives you the extra-time breaks free. The x-axis ends at `max(90, ceil(latest))`,
so a knockout match that reaches 121' is not clipped and a quiet one still shows a full 90.

## Reading a value at a minute

Hover or tap anywhere on the plot: a crosshair reports **every** series at that minute. That is the
question a race chart exists to answer, and a per-mark tooltip cannot answer it — you would have to
land on a line. Pass `tooltip` to replace the readout's body.

On a touch screen a horizontal drag scrubs the crosshair while a vertical one still scrolls the
page.

## Annotations

Events that accumulate nothing — bookings, substitutions, a red card — are not series. They are
children, positioned through `useRaceChart()`:

```tsx
function Card({ minute, team }: { minute: number; team: string }) {
  const { scaleX, scaleY, valueAt } = useRaceChart();
  return (
    <rect x={scaleX(minute) - 3} y={scaleY(valueAt(team, minute)) - 9} width={6} height={8} />
  );
}

<RaceChart ...>
  <Card minute={52} team="England" />
</RaceChart>;
```

`valueAt(seriesId, time)` is what anchors the mark **to** a line rather than leaving it floating: a
52' booking sits at whatever that team's cumulative xG was at 52'. It uses the same step-after
semantics as the line, so an event landing exactly on `time` is included.

```tsx
import { RaceChart, useRaceChart } from "@pitchkit/react";

const shots = [
  { minute: 11, team: "Spain", xg: 0.068, goal: false },
  { minute: 16, team: "England", xg: 0.049, goal: false },
  { minute: 27, team: "Spain", xg: 0.048, goal: false },
  { minute: 45, team: "England", xg: 0.18, goal: false },
  { minute: 46, team: "Spain", xg: 0.113, goal: true },
  { minute: 55, team: "Spain", xg: 0.243, goal: false },
  { minute: 66, team: "Spain", xg: 0.162, goal: false },
  { minute: 70, team: "England", xg: 0.075, goal: false },
  { minute: 72, team: "England", xg: 0.038, goal: true },
  { minute: 86, team: "Spain", xg: 0.283, goal: true },
  { minute: 89, team: "England", xg: 0.117, goal: false },
];

// A booking adds nothing to either running total, so it is not a series.
const bookings = [
  { minute: 24, team: "England", player: "Kane" },
  { minute: 29, team: "Spain", player: "Olmo" },
  { minute: 52, team: "England", player: "Stones" },
];

/**
 * `valueAt(seriesId, minute)` is what puts each card *on* its team's line
 * rather than floating beside it: Stones' 52' yellow sits at whatever
 * England's cumulative xG was at 52'.
 */
function Bookings() {
  const { scaleX, scaleY, valueAt } = useRaceChart();

  return (
    <g>
      {bookings.map((card) => (
        <rect
          key={`${card.team}-${card.minute}`}
          x={scaleX(card.minute) - 3}
          y={scaleY(valueAt(card.team, card.minute)) - 10}
          width={6}
          height={8}
          rx={1}
          style={{
            fill: "var(--pitch-card-yellow, #facc15)",
            // The surface ring keeps it legible where it crosses the line.
            stroke: "var(--pitch-chart-surface)",
            strokeWidth: 1.5,
          }}
        >
          <title>{`${card.player} booked, ${card.minute}'`}</title>
        </rect>
      ))}
    </g>
  );
}

/** Anything that doesn't accumulate is a child, not a series. */
export function RaceChartAnnotationsBasic() {
  return (
    <RaceChart
      series={[
        { id: "Spain", data: shots.filter((s) => s.team === "Spain") },
        { id: "England", data: shots.filter((s) => s.team === "England") },
      ]}
      time={(s) => s.minute}
      value={(s) => s.xg}
      emphasise={(s) => s.goal}
    >
      <Bookings />
    </RaceChart>
  );
}
```

## Shading under the line

```tsx
<RaceChart ... appearance={{ area: true }} />
```

Off by default. With two series the washes overlap exactly where the lines cross, which is the part
of the chart worth reading. It works well for a single series:

```tsx
import { RaceChart } from "@pitchkit/react";

// One team's shots. The area reads cleanly here precisely because there
// is a single series — with two, the washes overlap where the lines cross,
// which is the part of the chart worth reading.
const shots = [
  { minute: 11, xg: 0.068, goal: false },
  { minute: 12, xg: 0.118, goal: false },
  { minute: 27, xg: 0.048, goal: false },
  { minute: 35, xg: 0.027, goal: false },
  { minute: 42, xg: 0.078, goal: false },
  { minute: 46, xg: 0.113, goal: true },
  { minute: 48, xg: 0.246, goal: false },
  { minute: 55, xg: 0.243, goal: false },
  { minute: 66, xg: 0.162, goal: false },
  { minute: 69, xg: 0.033, goal: false },
  { minute: 81, xg: 0.164, goal: false },
  { minute: 86, xg: 0.283, goal: true },
];

/** `appearance.area` shades under each line at ~10% of the series hue. */
export function RaceChartAreaBasic() {
  return (
    <RaceChart
      series={[{ id: "Spain", label: "Spain", data: shots }]}
      time={(s) => s.minute}
      value={(s) => s.xg}
      emphasise={(s) => s.goal}
      appearance={{ area: true, markers: "all" }}
    />
  );
}
```

## Sizing

Responsive by default, like `<Pitch>` — it fills its container, so sizing one means sizing its
parent. Passing `width` **and** `height` is the opt-out. Below 420px the box gets squarer, because a
2:1 chart on a phone leaves a plot barely taller than its own axis labels.

## Theming

CSS variables only, as everywhere else.

| Variable                  | What it colours                            |
| ------------------------- | ------------------------------------------ |
| `--pitch-series-1` … `-6` | The series lines, by position in the array |
| `--pitch-axis`            | Baseline and period rules                  |
| `--pitch-grid`            | Gridlines                                  |
| `--pitch-chart-surface`   | The ring around each marker                |
| `--pitch-chart-text`      | Values and labels                          |
| `--pitch-chart-muted`     | Tick labels                                |

Each series' total is printed **at its own line end, inside the plot**, rather than in a
right-hand gutter, so the lines use the full width of the chart. The leading series labels above its
line and every other series below its own, so two teams finishing on close totals don't print one
number on top of the other. Only the value is printed, since the legend already names the series.
A label below its line clears the line's own earlier step rather than sitting on it, and the text
carries a halo in the surface colour so it stays legible wherever it does cross a line. The axis
ceiling leaves room above the highest total for the leader's label, so the top of the axis can sit
a little higher than the nearest round number; pin `maxValue` and you get exactly the axis you
asked for, with the label running into the padding above the plot. A trailing total too close to
the baseline to fit a label below goes above its line instead.

For anything the variables don't reach, every element carries a `data-pitchkit-part`, so a Tailwind
arbitrary variant can select it without owning the JSX: `race-line`, `race-area`, `race-marker`,
`race-emphasis`, `race-end-label`, `race-axis`, `race-grid`, `race-label`, `race-period`,
`race-legend`, `race-crosshair`. The root carries `data-pitchkit-layer="race"` and each series group
a `data-pitchkit-series` with its id.

Colour follows the entity, not its rank: a series takes its slot from its position in the `series`
array, so removing one never repaints the others. A series given a `className` and no `color` drops
its themed default, the same rule as every mark layer — the class goes on the series group, so one
utility reaches the line, the area and the markers.

## Live data

The same chart against a real Euro 2024 match, fetched in the browser.

```tsx
import { useEffect, useState } from "react";
import { RaceChart } from "@pitchkit/react";
import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb";
import type { StatsBombShot } from "@pitchkit/data-providers/statsbomb";
import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";

/**
 * An xG race from a real Euro 2024 match, fetched in the browser.
 *
 * One line does the work that matters:
 *
 *     shots(events).filter((s) => s.period <= 4)
 *
 * That filter is not optional. StatsBomb's period 5 is the penalty
 * shootout, and shootout penalties carry xG like any other shot — in the
 * England–Switzerland quarter-final they add 7.05 xG on top of 1.74 from
 * the match itself. Without the filter, every knockout tie that goes to
 * penalties draws a chart several times too tall.
 */
export function RaceChartStatsbombBasic() {
  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);
  const [result, setResult] = useState<{ key: number; shots: StatsBombShot[] } | undefined>();
  const [failed, setFailed] = useState(false);
  const matches = useEuroMatches();

  const loaded = result?.key === matchId ? result.shots : undefined;

  useEffect(() => {
    fetchMatchEvents(matchId)
      .then((events) =>
        setResult({ key: matchId, shots: shots(events).filter((s) => s.period <= 4) }),
      )
      .catch(() => setFailed(true));
  }, [matchId]);

  // Home team first, so the two lines keep their colours as you change match.
  const match = matches.find((m) => m.match_id === matchId);
  const teams = match
    ? [match.home_team.home_team_name, match.away_team.away_team_name]
    : [...new Set((loaded ?? []).map((s) => s.team.name))];

  return (
    <div>
      <select
        aria-label="Euro 2024 match"
        value={matchId}
        disabled={matches.length === 0}
        onChange={(event) => {
          setFailed(false);
          setMatchId(Number(event.target.value));
        }}
        className={`w-full min-w-0 pl-2 pr-8 sm:w-auto sm:max-w-xs ${controlClass}`}
      >
        {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>}
        {matches.map((m) => (
          <option key={m.match_id} value={m.match_id}>
            {matchLabel(m)}
          </option>
        ))}
      </select>
      <p className="my-3 text-xs text-fd-muted-foreground">
        {failed
          ? "Couldn't reach StatsBomb open data."
          : loaded === undefined
            ? "Fetching the match from StatsBomb open data (~3 MB)…"
            : `${loaded.length} shots · ${loaded.filter(isGoal).length} goals`}
      </p>

      <RaceChart
        series={teams.map((team) => ({
          id: team,
          data: (loaded ?? []).filter((s) => s.team.name === team),
        }))}
        time={(s) => s.minute + s.second / 60}
        value={(s) => s.shot.statsbomb_xg}
        emphasise={isGoal}
        period={(s) => s.period}
        tooltip={(rows, minute) => (
          <>
            <div className="font-semibold">{`${Math.round(minute)}'`}</div>
            {rows.map((row) => (
              <div key={row.id} className="flex items-center gap-1.5">
                <span
                  className="h-0.5 w-2.5 shrink-0 rounded-full"
                  style={{ background: row.color }}
                />
                <span className="opacity-75">{row.label}</span>
                <span className="ml-auto font-semibold tabular-nums">{row.value.toFixed(2)}</span>
              </div>
            ))}
          </>
        )}
      />
    </div>
  );
}
```

Note the `period <= 4` filter in the source. StatsBomb's period 5 is the **penalty shootout**, and
shootout penalties carry xG like any other shot — in the England–Switzerland quarter-final they add
7.05 xG on top of the 1.74 from the match itself. Without that filter every tie that goes to
penalties draws a chart several times too tall.
