# MomentumChart

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

> Match momentum as bars above and below a zero line, one panel per half, with goals, cards and other events on a row of icons beneath.

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

// Momentum every three minutes in each half. The interval is yours to pick —
// nothing has to be one minute — but use the same one in both halves, or the
// bars come out different widths on either side of half time.
// Positive is the home side's pressure, negative the away side's.
const periods = [
  [
    { minute: 0, value: 2 },
    { minute: 3, value: 5 },
    { minute: 6, value: 8 },
    { minute: 9, value: 4 },
    { minute: 12, value: -1 },
    { minute: 15, value: -4 },
    { minute: 18, value: -7 },
    { minute: 21, value: -3 },
    { minute: 24, value: 1 },
    { minute: 27, value: 4 },
    { minute: 30, value: 7 },
    { minute: 33, value: 5 },
    { minute: 36, value: 2 },
    { minute: 39, value: -2 },
    { minute: 42, value: -5 },
  ],
  [
    { minute: 45, value: -2 },
    { minute: 48, value: -5 },
    { minute: 51, value: -8 },
    { minute: 54, value: -4 },
    { minute: 57, value: 1 },
    { minute: 60, value: 5 },
    { minute: 63, value: 9 },
    { minute: 66, value: 7 },
    { minute: 69, value: 3 },
    { minute: 72, value: -1 },
    { minute: 75, value: -4 },
    { minute: 78, value: -6 },
    { minute: 81, value: -2 },
    { minute: 84, value: 3 },
    { minute: 87, value: 7 },
  ],
];

// Events are a separate list, with accessors, like the samples.
const events = [
  { minute: 23, side: "away", kind: "goal" },
  { minute: 38, side: "home", kind: "yellow-card" },
  { minute: 56, side: "home", kind: "goal" },
  { minute: 71, side: "away", kind: "red-card" },
  { minute: 81, side: "away", kind: "missed-penalty" },
  { minute: 88, side: "home", kind: "goal" },
] as const;

export function MomentumChartBasic() {
  return (
    <MomentumChart
      periods={periods}
      time={(d) => d.minute}
      value={(d) => d.value}
      teams={{ home: "Home", away: "Away" }}
      events={events}
      eventTime={(e) => e.minute}
      eventSide={(e) => e.side}
      eventKind={(e) => e.kind}
    />
  );
}
```

`<MomentumChart>` draws who had the upper hand, minute by minute. The home side's pressure rises
above a zero line and the away side's falls below it; each half gets its own panel, and the events
that shaped it — goals, cards, a missed penalty — sit on a row of icons underneath.

It is a root, not a layer, like [`<RaceChart>`](/docs/charts/race-chart): no `<Pitch>`, no `type`
prop. See [Charts](/docs/charts) for why.

## Where the numbers come from

PitchKit draws momentum; it does not compute it. There is no standard momentum metric — vendors
each publish their own and none of them is in an open feed — so you bring the values. A
[StatsBomb recipe](#live-data) is below for when all you have is an event stream.

## Usage

```tsx
<MomentumChart
  periods={[firstHalf, secondHalf]}
  time={(d) => d.minute}
  value={(d) => d.value}
  teams={{ home: "Roma", away: "Barcelona" }}
  events={events}
  eventTime={(e) => e.minute}
  eventSide={(e) => e.side}
  eventKind={(e) => e.kind}
/>
```

`periods` is **one array of samples per period**: `[firstHalf, secondHalf]`, and two more for extra
time. `time` and `value` are accessors like every other PitchKit prop.

`value` is **signed**. Positive is the home side's pressure and negative is the away side's, so one
number per sample carries both teams. Zero is level.

## Data at any interval

Samples do not need to be a minute apart, or evenly spaced. The example above has one every three
minutes. Use the **same interval in both halves**, though: two halves at different intervals draw
bars of different widths either side of half time, which reads as a bug. In development the chart
warns when the median bar widths of two periods differ by more than a quarter.

Each sample draws a bar that **runs from its minute to the next sample's minute**. That is what
makes an arbitrary interval readable: a value given at 10' and the next at 15' is five minutes wide,
not a thin sliver followed by a hole. The last bar of a period has no successor, so it takes the
median interval of the period — one minute, if there is only a single sample.

A few consequences worth knowing:

* **Gaps draw nothing.** If you leave out a stretch, it is empty, and hovering it reads "No data",
  not "Level". Level is a value of 0 and means something.
* **Duplicates: the later one wins.** Samples are sorted by time first, so input order doesn't matter.
* **Non-finite values are dropped**, not drawn as zero.

## Halves, and stoppage time

A panel's width is proportional to its minutes, so a half with seven minutes of stoppage time is
wider than one with two, and a bar is the same width in either half for the same number of minutes.

Each period starts at its nominal minute (0, 45, 90, 105) and ends at the later of its nominal end
(45, 90, 105, 120) and its last sample. Half time therefore comes from the data, for the same reason
as in the race chart: **halves do not end on 45**. Pass `periodRanges` to override either end:

```tsx
<MomentumChart ... periodRanges={[undefined, { start: 45, end: 95 }]} />
```

Extra time is two more arrays in `periods`; the axis then ends at 105 and 120. Periods beyond the
fourth continue in 15-minute blocks.

## The value axis

The axis is symmetric about zero and spans the largest magnitude in the data, rounded up to a round
number, so the two teams are always on the same scale. Pin it with `maxValue` — useful for putting
several matches side by side on one scale.

A bar that would be under a pixel tall is drawn at one pixel, so a small but real value never
vanishes.

## Events

Events are a separate list, passed like the race chart's annotations: a list plus accessors.

| Prop         | What it is                                       |
| ------------ | ------------------------------------------------ |
| `events`     | The list                                         |
| `eventTime`  | Match minute                                     |
| `eventSide`  | `"home"` or `"away"`                             |
| `eventKind`  | One of the kinds below                           |
| `eventLabel` | Optional text for the readout and screen readers |

These come as a set: pass `events` and TypeScript requires the three accessors, so a missing one is
a compile error rather than an empty icon row.

| Kind             | Icon                            | Colour                |
| ---------------- | ------------------------------- | --------------------- |
| `goal`           | Ball                            | Team                  |
| `own-goal`       | Ball with an arrow turning back | `--pitch-card-red`    |
| `missed-penalty` | Ball struck through             | Team                  |
| `yellow-card`    | Card                            | `--pitch-card-yellow` |
| `red-card`       | Card                            | `--pitch-card-red`    |
| `substitution`   | Up and down arrows              | Team                  |
| `var`            | Screen showing a V              | Team                  |

`goal` includes a scored penalty — most feeds record those as goals. A missed one is its own kind,
`missed-penalty`, because it is the event people look for.

Cards and own goals carry their meaning in their colour, so they cannot also carry the team. Those
get a small underline in the team's colour; every other icon is simply the team's colour. The
icons are PitchKit's own, drawn for this library on a 24-unit grid, and inherit `currentColor`.

### Keep the row readable

The row is always one row tall. Icons that would touch **stack with an offset**: each sits a little
right of the last and over it, on a backing in the chart surface colour, and a stack is centred on
where its events really were. A goal and a booking in the same minute read as two icons, not one
smudge. Every substitution in a close finish is still a pile, especially on a phone. If you want
substitutions, prefer them as [annotations](#annotations) — a thin line rather than an icon — or
leave them out, which is what the examples do.

## Reading a value at a minute

Hover or tap a panel. A crosshair marks the minute, and a readout gives the team and value of the
bar under it, plus any events at that minute. On a touch screen a horizontal drag scrubs the
crosshair and a vertical one still scrolls the page. Pass `tooltip` to replace the body:

```tsx
<MomentumChart ... tooltip={(h) => `${Math.round(h.minute)}' · ${h.datum?.value ?? "no data"}`} />
```

`h.datum` is your own sample behind the bar, so a custom readout can show fields the chart never
saw.

## Annotations

Anything the built-in kinds don't cover is a child, positioned through `useMomentumChart()`:

```tsx
function Change({ minute }: { minute: number }) {
  const { scaleX, frame } = useMomentumChart();
  const x = scaleX(minute);
  return <line x1={x} x2={x} y1={frame.y0} y2={frame.y1} />;
}
```

It returns `frame` (the bars' rectangle), `panels`, `scaleX(minute)` (into whichever half holds that
minute), `scaleY(value)`, and the computed `bars`. Children paint above the bars and below the
crosshair.

```tsx
import { MomentumChart, useMomentumChart } from "@pitchkit/react";

const periods = [
  Array.from({ length: 10 }, (_, i) => ({
    minute: i * 5,
    value: Math.round(8 * Math.sin(i / 1.6)),
  })),
  Array.from({ length: 10 }, (_, i) => ({
    minute: 45 + i * 5,
    value: Math.round(9 * Math.cos(i / 1.4)),
  })),
];

const substitutions = [60, 70, 78];

/**
 * A marker for something the built-in kinds don't cover. `scaleX` turns a
 * minute into a pixel in whichever half holds it, and `frame` gives the bars'
 * rectangle, so this line runs the full height of the plot.
 */
function Change({ minute }: { minute: number }) {
  const { scaleX, frame } = useMomentumChart();
  const x = scaleX(minute);

  return (
    <line
      x1={x}
      x2={x}
      y1={frame.y0}
      y2={frame.y1}
      strokeDasharray="2 3"
      style={{ stroke: "var(--pitch-chart-text)", strokeOpacity: 0.7 }}
    >
      <title>{`Substitution, ${minute}'`}</title>
    </line>
  );
}

export function MomentumChartAnnotationsBasic() {
  return (
    <MomentumChart periods={periods} time={(d) => d.minute} value={(d) => d.value}>
      {substitutions.map((minute) => (
        <Change key={minute} minute={minute} />
      ))}
    </MomentumChart>
  );
}
```

## Sizing

Responsive by default: it fills its container, so sizing one means sizing its parent. Passing
`width` **and** `height` is the opt-out. The box is 3:1, and below 420px wide it is 1.8:1 with
smaller icons, because a 3:1 chart on a phone leaves bars a few pixels tall.

## Theming

CSS variables only.

| Variable                | What it colours                |
| ----------------------- | ------------------------------ |
| `--pitch-series-1`      | The home side's bars and icons |
| `--pitch-series-2`      | The away side's bars and icons |
| `--pitch-card-yellow`   | Yellow cards                   |
| `--pitch-card-red`      | Red cards and own goals        |
| `--pitch-grid`          | The panel backgrounds          |
| `--pitch-axis`          | The zero line                  |
| `--pitch-chart-surface` | Halos and outlines             |
| `--pitch-chart-text`    | Labels and the readout         |
| `--pitch-chart-muted`   | Minute ticks                   |

`appearance` is structural only: `axis` (minute ticks, default on) and `legend` (the two teams and
which way is which, default on when `teams` is given).

Every element carries a `data-pitchkit-part` for Tailwind arbitrary variants: `momentum-bar`,
`momentum-zero`, `momentum-label`, `momentum-event`, `momentum-crosshair`. Bars and events carry
`data-pitchkit-side`, events `data-pitchkit-kind`, and the root `data-pitchkit-layer="momentum"`.

## Live data

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

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

/**
 * Match momentum from a real Euro 2024 match, fetched in the browser.
 *
 * StatsBomb does not publish a momentum number, so this derives one, and
 * says so: on-ball events in the attacking third, per minute, home minus
 * away, smoothed over three minutes. It is a stand-in for pressure, not
 * anyone's official metric.
 *
 * The attacking third is `x >= 80` for every team, because StatsBomb
 * orients each team to attack towards x = 120 in both halves.
 */
const ON_BALL = new Set([
  "Pass",
  "Carry",
  "Shot",
  "Dribble",
  "Ball Receipt*",
  "Duel",
  "Interception",
  "Ball Recovery",
  "Clearance",
  "Miscontrol",
  "Dispossessed",
]);

interface Sample {
  minute: number;
  value: number;
}

interface MatchEvent {
  minute: number;
  side: "home" | "away";
  kind: MomentumEventKind;
  label: string;
}

/** One array of samples per period, the shape `<MomentumChart>` takes. */
function derivePeriods(events: readonly StatsBombEvent[], home: string): Sample[][] {
  const periods: Sample[][] = [];

  // Period 5 is the penalty shootout; it has no place on a match clock.
  for (let period = 1; period <= 4; period++) {
    const inPeriod = events.filter((e) => e.period === period);
    if (inPeriod.length === 0) continue;

    const counts = new Map<number, number>();
    for (const e of inPeriod) {
      if (!ON_BALL.has(e.type.name) || e.x === undefined || e.x < 80) continue;
      counts.set(e.minute, (counts.get(e.minute) ?? 0) + (e.team.name === home ? 1 : -1));
    }
    if (counts.size === 0) continue;

    const first = Math.min(...inPeriod.map((e) => e.minute));
    const last = Math.max(...counts.keys());
    const raw = Array.from({ length: last - first + 1 }, (_, i) => counts.get(first + i) ?? 0);

    periods.push(
      raw.map((_, i) => {
        const window = raw.slice(Math.max(0, i - 1), i + 2);
        const mean = window.reduce((sum, v) => sum + v, 0) / window.length;
        return { minute: first + i, value: Math.round(mean * 10) / 10 };
      }),
    );
  }
  return periods;
}

/** Goals and bookings. Bookings live at `foul_committed.card` in StatsBomb's feed. */
function pickEvents(events: readonly StatsBombEvent[], home: string): MatchEvent[] {
  const picked: MatchEvent[] = [];

  for (const shot of shots(events)) {
    if (shot.period <= 4 && isGoal(shot)) {
      picked.push({
        minute: shot.minute + shot.second / 60,
        side: shot.team.name === home ? "home" : "away",
        kind: "goal",
        label: `Goal, ${shot.player?.name ?? shot.team.name}`,
      });
    }
  }

  for (const e of events) {
    const card = (e as { foul_committed?: { card?: { name: string } } }).foul_committed?.card?.name;
    if (!card || e.period > 4) continue;
    picked.push({
      minute: e.minute + e.second / 60,
      side: e.team.name === home ? "home" : "away",
      kind: card === "Yellow Card" ? "yellow-card" : "red-card",
      label: `${card}, ${e.player?.name ?? e.team.name}`,
    });
  }
  return picked;
}

export function MomentumChartStatsbombBasic() {
  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);
  const [result, setResult] = useState<
    { key: number; events: readonly StatsBombEvent[] } | undefined
  >();
  const [failed, setFailed] = useState(false);
  const matches = useEuroMatches();

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

  useEffect(() => {
    fetchMatchEvents(matchId)
      .then((events) => setResult({ key: matchId, events }))
      .catch(() => setFailed(true));
  }, [matchId]);

  const match = matches.find((m) => m.match_id === matchId);
  const home = match?.home_team.home_team_name ?? "";
  const away = match?.away_team.away_team_name ?? "";

  const periods = loaded && home ? derivePeriods(loaded, home) : [];
  const marks = loaded && home ? pickEvents(loaded, home) : [];

  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)…"
            : "Derived from attacking-third on-ball events, smoothed over three minutes."}
      </p>

      <MomentumChart
        periods={periods}
        time={(d) => d.minute}
        value={(d) => d.value}
        teams={{ home, away }}
        events={marks}
        eventTime={(e) => e.minute}
        eventSide={(e) => e.side}
        eventKind={(e) => e.kind}
        eventLabel={(e) => e.label}
      />
    </div>
  );
}
```

StatsBomb does not publish momentum, so this example **derives** one — a stand-in for pressure, not
anyone's official metric:

1. Keep on-ball events (`Pass`, `Carry`, `Shot`, `Dribble`, `Ball Receipt*`, `Duel`,
   `Interception`, `Ball Recovery`, `Clearance`, `Miscontrol`, `Dispossessed`) that happen in the
   attacking third.
2. Count them per minute, **+1 for the home side and −1 for the away side**.
3. Smooth with a three-minute centred moving average.

The attacking third is `x >= 80` for **both** teams, because StatsBomb orients every team to attack
towards x = 120 in both halves. You don't flip the away side's coordinates.

The source filters to `period <= 4` for the same reason the race chart does: period 5 is the
penalty shootout, and it has no place on a match clock.
