# Metrica tracking and events

Source: https://www.pitchkitjs.com/docs/data/metrica/tracking

> Metrica Sports' sample games — 25 fps tracking and the events on the same clock, typed and plotted.

Everything below is live: the example reads Metrica's sample data in your browser, picks out
every goal from the events file, and replays the five seconds before it from the tracking
data. Only those frames are downloaded, not the two 32 MB files they come from.

```tsx
import { useEffect, useState } from "react";
import { Pitch, Scatter, Voronoi } from "@pitchkit/react";
import {
  fetchEvents,
  fetchTrackingWindow,
  isGoal,
  isOwnGoal,
  shots,
} from "@pitchkit/data-providers/metrica";
import type { MetricaEvent, MetricaFrame } from "@pitchkit/data-providers/metrica";

/** Metrica track at 25 fps, so this plays back in real time. */
const FPS = 25;
/** Five seconds of build-up before the goal, and one after it goes in. */
const BEFORE = 5 * FPS;
const AFTER = FPS;

const selectClass =
  "w-full min-w-0 rounded-md border border-fd-border bg-fd-card py-1.5 pl-2 pr-8 text-sm disabled:opacity-50 sm:w-auto sm:max-w-xs";

/**
 * Every goal in a game, own goals included. Metrica record an own goal as a
 * `BALL OUT` by the player who put it in, so `shots(...).filter(isGoal)` alone
 * would miss Sample Game 1's only away goal.
 */
async function loadGoals(game: number, signal: AbortSignal): Promise<MetricaEvent[]> {
  const events = await fetchEvents(game, { signal });
  return [...shots(events).filter(isGoal), ...events.filter(isOwnGoal)].sort(
    (a, b) => a["Start Frame"] - b["Start Frame"],
  );
}

/**
 * The frames around one goal. Events and tracking share a clock, so the
 * goal's own `Start Frame` is the window's anchor. `fetchTrackingWindow`
 * reads only those rows from each team's 32 MB file, with `Range` requests.
 */
async function loadClip(game: number, goal: MetricaEvent, signal: AbortSignal) {
  return fetchTrackingWindow(game, {
    fromFrame: goal["Start Frame"] - BEFORE,
    toFrame: goal["End Frame"] + AFTER,
    signal,
  });
}

function goalLabel(goal: MetricaEvent): string {
  const seconds = Math.floor(goal["Start Time [s]"]);
  const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
  // An own goal's Team is the side that conceded it.
  return isOwnGoal(goal)
    ? `${clock} · ${goal.Team} own goal (${goal.From})`
    : `${clock} · ${goal.Team} goal (${goal.From})`;
}

/** Advances a frame index at the data's own rate, looping at the end. */
function usePlayhead(length: number) {
  const [at, setAt] = useState(0);
  useEffect(() => {
    if (length === 0) return;
    const id = setInterval(() => setAt((current) => (current + 1) % length), 1000 / FPS);
    return () => clearInterval(id);
  }, [length]);
  return Math.min(at, Math.max(length - 1, 0));
}

/**
 * Every goal in Metrica's two sample games, replayed from the tracking data
 * at 25 fps: each team's players, the ball, and the scorer ringed in white.
 *
 * Coordinates go in **raw**. `<Pitch type="metrica">` is Metrica's own `0..1`
 * grid, origin top-left with y downward, so the accessors are just `(p) => p.x`.
 */
export function MetricaTrackingBasic() {
  const [game, setGame] = useState(1);
  const [loaded, setLoaded] = useState<{ game: number; goals: MetricaEvent[] }>();
  const [goalIndex, setGoalIndex] = useState(0);
  const [clip, setClip] = useState<{ goal: MetricaEvent; frames: MetricaFrame[] }>();
  // Which load failed, so picking another game or goal clears the message.
  const [failedLoad, setFailedLoad] = useState<string>();

  useEffect(() => {
    const controller = new AbortController();
    loadGoals(game, controller.signal)
      .then((goals) => {
        setLoaded({ game, goals });
        setFailedLoad(undefined);
      })
      .catch(() => !controller.signal.aborted && setFailedLoad(`events-${game}`));
    return () => controller.abort();
  }, [game]);

  const goals = loaded?.game === game ? loaded.goals : [];
  const goal = goals[goalIndex];
  useEffect(() => {
    if (!goal) return;
    const controller = new AbortController();
    loadClip(game, goal, controller.signal)
      .then((frames) => {
        setClip({ goal, frames });
        setFailedLoad(undefined);
      })
      .catch(
        () => !controller.signal.aborted && setFailedLoad(`clip-${game}-${goal["Start Frame"]}`),
      );
    return () => controller.abort();
  }, [game, goal]);

  const frames = clip && clip.goal === goal ? clip.frames : [];
  const failed =
    failedLoad === `events-${game}` ||
    (goal !== undefined && failedLoad === `clip-${game}-${goal["Start Frame"]}`);
  const frame = frames[usePlayhead(frames.length)];
  const fill = (p: { team: string }) =>
    p.team === "Home" ? "var(--pitch-marker-primary)" : "var(--pitch-marker-goal)";

  return (
    <div>
      <div className="flex flex-col gap-2 sm:flex-row">
        <select
          aria-label="Metrica sample game"
          value={game}
          onChange={(event) => {
            setGame(Number(event.target.value));
            setGoalIndex(0);
          }}
          className={selectClass}
        >
          <option value={1}>Sample Game 1</option>
          <option value={2}>Sample Game 2</option>
        </select>
        <select
          aria-label="Goal"
          value={goalIndex}
          disabled={goals.length === 0}
          onChange={(event) => setGoalIndex(Number(event.target.value))}
          className={selectClass}
        >
          {goals.length === 0 && <option>Loading events…</option>}
          {goals.map((g, i) => (
            <option key={g["Start Frame"]} value={i}>
              {goalLabel(g)}
            </option>
          ))}
        </select>
      </div>
      <p className="my-3 text-xs text-fd-muted-foreground">
        {failed
          ? "Couldn't reach Metrica's sample data."
          : frame
            ? `${frames.length} frames · 105×68 m pitch · playing at ${FPS} fps`
            : "Reading the frames around the goal…"}
      </p>

      <Pitch type="metrica">
        {frame && (
          <>
            <Voronoi
              data={frame.players}
              x={(p) => p.x}
              y={(p) => p.y}
              fill={fill}
              fillOpacity={0.13}
              stroke="rgba(255,255,255,0.18)"
              strokeWidth={0.4}
            />
            <Scatter
              data={frame.players}
              x={(p) => p.x}
              y={(p) => p.y}
              // The event's `From` is the tracking column's own spelling, so
              // this is how the scorer is picked out.
              r={(p) => (p.player === goal?.From ? 3.6 : 2.4)}
              fill={fill}
              stroke={(p) => (p.player === goal?.From ? "#fff" : fill(p))}
              strokeWidth={(p) => (p.player === goal?.From ? 1.2 : 0.7)}
              tooltip={(p) => `${p.team} #${p.jersey ?? "?"}`}
            />
            {/* Untracked in about 40% of frames, mostly while play is stopped. */}
            {frame.ball && (
              <Scatter
                data={[frame.ball]}
                x={(b) => b.x}
                y={(b) => b.y}
                r={1.4}
                fill="#fff"
                stroke="#111"
                strokeWidth={0.4}
              />
            )}
          </>
        )}
      </Pitch>
    </div>
  );
}
```

## Two games, two kinds of file

Metrica publish two anonymised games in CSV: one events file per game, and one tracking file
per team. Teams are only ever `"Home"` and `"Away"`, players `"Player1"` to `"Player28"`.

```ts
import { fetchEvents, fetchTrackingWindow, streamTracking } from "@pitchkit/data-providers/metrica";

const events = await fetchEvents(1); // ~150 KB, under 2,000 rows
const frames = await fetchTrackingWindow(1, { fromFrame: 2164, toFrame: 2309 });
```

`METRICA_SAMPLE_GAMES` is `[1, 2]`.

<Callout type="warn">
  Sample Game 3 is in a different format, FIFA EPTS tracking with XML metadata and JSON events, and
  these loaders don't read it.
</Callout>

## Events and tracking share a clock

This is what makes the data worth loading together. An event's `"Start Frame"` is a tracking
`Frame`, and its `From` and `To` are the tracking file's own column headers, so placing the
player behind an event takes no mapping:

```ts
import { findPlayer, isGoal, shots } from "@pitchkit/data-providers/metrica";

const goal = shots(events).find(isGoal)!;
const atShot = frames.find((frame) => frame.Frame === goal["Start Frame"])!;
findPlayer(atShot, goal.From); // { team: "Home", player: "Player9", jersey: 9, x: 0.918, y: 0.467 }
```

Measured across both games, a pass's start position sits a median 0.004 grid units
from the passer's tracked position on its start frame.

## Field names are Metrica's headers

Rows keep Metrica's CSV headers as their keys, spaces and units included, so their event
definitions read against the types with no mapping table. What the package adds is
lowercase:

```ts
goal["Start Frame"]; // 2289
goal.Subtype; // "HEAD-ON TARGET-GOAL"
goal.x; // 0.92, lifted from "Start X" for an accessor

frame["Time [s]"]; // 91.56
frame.ball; // { x, y } or null
frame.players; // [{ team, player, jersey, x, y }, …]
```

A `NaN` cell becomes `null`, and a lifted `x`/`y` is absent rather than `NaN`. Every
`SET PIECE` and `CARD` lacks a start position; recoveries and challenges lack an end.

<Callout>
  Player names are kept exactly as the header spells them. Sample Game 2 has a `"Player 26"`, with a
  space, in both its tracking and events files, so a match on `From` still works.
</Callout>

## Coordinates are absolute

The grid is `0..1` on both axes, origin top-left with y increasing downward, on a 105×68 m
pitch. `<Pitch type="metrica">` plots it raw.

Teams **swap ends at half time**, and which end each side starts at differs between the games:
Home attack towards `x = 1` in Game 1's first half and towards `x = 0` in Game 2's. Neither
file says so, so `attackingDirection` reads it from where the players stand:

```ts
import { attackingDirection } from "@pitchkit/data-providers/metrica";

const [kickOff] = await fetchTrackingWindow(1, { fromFrame: 1, toFrame: 1 });
attackingDirection(kickOff, "Home"); // "left_to_right"
```

<Callout type="warn">
  Pass a **kick-off frame**, where every player is in their own half. Mid-play the answer can be
  wrong: 39 frames before Game 1's opening goal, Home are camped in the Away half.
</Callout>

## Qualifiers live in `Subtype`

Everything beyond the event type is a hyphen-joined list of qualifiers. `hasSubtype` reads one,
and the named predicates are built on it:

```ts
import { hasSubtype, isCross, isOnTarget, wonChallenge } from "@pitchkit/data-providers/metrica";

hasSubtype(goal, "HEAD"); // true
hasSubtype(goalKick, "GOAL"); // false: "GOAL KICK" is one qualifier
```

A `PASS` always reached a teammate; one that didn't is a `BALL LOST` or `BALL OUT`. A goal
kick is a `PASS` qualified `GOAL KICK` (`isGoalKick`), never a `SET PIECE`.

## An own goal is not a shot

Metrica record an own goal as a `BALL OUT` qualified `GOAL`, by the player who put it in, so
its `Team` is the side that conceded. `shots(events).filter(isGoal)` therefore misses Game 1's
only away goal. The example above adds `events.filter(isOwnGoal)`:

```ts
const goals = [...shots(events).filter(isGoal), ...events.filter(isOwnGoal)];
```

## Reading tracking without downloading it

A game's tracking is two files, one per team, of about 32 MB and 140,000-odd frames each. Three
ways in:

* **`fetchTrackingWindow(game, { fromFrame, toFrame })`** reads just those rows with HTTP
  `Range` requests. A goal's build-up costs about six requests and 220 KB, against 65 MB for the
  game.
* **`streamTracking(game)`** is an async generator over both files at once. Leaving the loop
  cancels both downloads.
* **`fetchTracking(game)`** reads both files whole. That suits a server; in a browser it is
  65 MB.

`frame.ball` is `null` in about 40% of frames, mostly while play is stopped. A player who isn't
on the pitch (a substitute yet to come on) is left out of `frame.players`.

If the files are on disk, `parseEvents(csv)` and `parseTracking(homeCsv, awayCsv)` do the same
work with no network.

## Official documentation

* **[metrica-sports/sample-data](https://github.com/metrica-sports/sample-data)** — the
  repository this package reads from.
* **[Event definitions](https://github.com/metrica-sports/sample-data/blob/master/documentation/events-definitions.pdf)**
  — every event type and subtype, which is what `hasSubtype` and the predicates read.

The repository has no licence file. Its README asks that anything public made from the data
acknowledges Metrica Sports as the source.
