# 360 Tracking

Source: https://www.pitchkitjs.com/docs/data/statsbomb/360

> Every visible player's position at the moment of an event — joined onto the events feed.

StatsBomb 360 is optical tracking: for events the broadcast camera could see, a freeze frame of
where every visible player was standing. It's a separate file per match, joined onto the
[events feed](/docs/data/statsbomb/events) by id.

Pick a match below and step through it two moments at a time — each pitch is one tracked
event, with a Voronoi diagram of the space each player was closest to. Both files load
together, so give it a moment: that's around 10 MB of real data.

```tsx
import { useEffect, useState } from "react";
import { Polygon, Scatter, VerticalPitch, Voronoi } from "@pitchkit/react";
import {
  fetchMatchEvents,
  fetchMatchThreeSixty,
  indexThreeSixtyByEvent,
  isKeeper,
  visibleAreaPolygon,
} from "@pitchkit/data-providers/statsbomb";
import type {
  StatsBombEvent,
  StatsBombThreeSixtyFrame,
  StatsBombThreeSixtyPlayer,
} from "@pitchkit/data-providers/statsbomb";
import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";

const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const;

interface Moment {
  event: StatsBombEvent;
  frame: StatsBombThreeSixtyFrame;
}

/** Every Euro 2024 fixture, in kickoff order, over a line of status text. */
function MatchSelector({
  value,
  onChange,
  status,
}: {
  value: number;
  onChange: (matchId: number) => void;
  status: string;
}) {
  const matches = useEuroMatches();

  return (
    <>
      <select
        aria-label="Euro 2024 match"
        value={value}
        disabled={matches.length === 0}
        onChange={(event) => onChange(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((match) => (
          <option key={match.match_id} value={match.match_id}>
            {matchLabel(match)}
          </option>
        ))}
      </select>
      <p className="my-3 text-xs text-fd-muted-foreground">{status}</p>
    </>
  );
}

/**
 * The two files joined into one list: every event that has both a location
 * and a 360 frame, in StatsBomb's own play order (`index`).
 */
function join(
  events: readonly StatsBombEvent[],
  frames: readonly StatsBombThreeSixtyFrame[],
): Moment[] {
  const frameByEvent = indexThreeSixtyByEvent(frames);
  const moments: Moment[] = [];
  for (const event of events) {
    if (typeof event.x !== "number") continue;
    const frame = frameByEvent.get(event.id);
    if (frame) moments.push({ event, frame });
  }
  return moments.sort((a, b) => a.event.index - b.event.index);
}

/**
 * `teammate` is relative to whoever performed the current event, so left
 * alone the colours would swap sides on every change of possession.
 * Resolving it to the match's real team names keeps a colour meaning one
 * team throughout.
 */
function colorOf(player: StatsBombThreeSixtyPlayer, moment: Moment, teams: string[]): string {
  const team = player.teammate
    ? moment.event.team.name
    : teams.find((name) => name !== moment.event.team.name);
  return team === teams[0] ? TEAM_COLORS[0] : TEAM_COLORS[1];
}

/**
 * `minute` runs continuously across periods (a 92nd-minute event really is
 * `minute: 92`), so mm:ss needs no stoppage-time special case.
 */
function clockLabel(event: StatsBombEvent): string {
  return `${String(event.minute).padStart(2, "0")}:${String(event.second).padStart(2, "0")}`;
}

/** One tracked moment: who was where, and the space each player was closest to. */
function MomentPitch({ moment, teams }: { moment: Moment; teams: string[] }) {
  const fill = (player: StatsBombThreeSixtyPlayer) => colorOf(player, moment, teams);

  return (
    <figure className="m-0">
      <VerticalPitch type="statsbomb">
        <Polygon
          data={[moment.frame]}
          points={(frame: StatsBombThreeSixtyFrame) => visibleAreaPolygon(frame)}
          fill="none"
          stroke="rgba(255,255,255,0.15)"
          strokeWidth={1}
        />
        <Voronoi
          data={moment.frame.freeze_frame}
          x={(player) => player.x}
          y={(player) => player.y}
          fill={fill}
          fillOpacity={0.14}
          stroke="rgba(255,255,255,0.2)"
          strokeWidth={0.5}
        />
        <Scatter
          data={moment.frame.freeze_frame}
          x={(player) => player.x}
          y={(player) => player.y}
          r={(player) => (player.actor ? 5.5 : isKeeper(player) ? 5 : 3.5)}
          fill={fill}
          stroke={(player) => (player.actor ? "white" : "rgba(255,255,255,0.7)")}
          strokeWidth={(player) => (player.actor ? 2.5 : 1)}
          tooltip={(player) =>
            player.actor ? "On the ball" : isKeeper(player) ? "Goalkeeper" : undefined
          }
        />
      </VerticalPitch>
      <figcaption className="mt-1 text-xs tabular-nums text-fd-muted-foreground">
        {clockLabel(moment.event)} · {moment.event.type.name} · {moment.event.team.name}
      </figcaption>
    </figure>
  );
}

/**
 * Two consecutive tracked moments, side by side — step through the match a
 * pair at a time.
 *
 * Both files are fetched together, so picking a match pulls around 10 MB.
 */
export function Statsbomb360Basic() {
  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);
  // Keyed by the match it belongs to, so "still loading" is derived rather
  // than a second state field.
  const [result, setResult] = useState<
    { key: number; moments: Moment[]; teams: string[] } | undefined
  >();
  const [failed, setFailed] = useState(false);
  const [at, setAt] = useState(0);

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

  useEffect(() => {
    Promise.all([fetchMatchEvents(matchId), fetchMatchThreeSixty(matchId)])
      .then(([events, frames]) =>
        setResult({
          key: matchId,
          moments: join(events, frames),
          teams: [...new Set(events.map((event) => event.team.name))],
        }),
      )
      .catch(() => setFailed(true));
  }, [matchId]);

  const moments = loaded?.moments ?? [];
  const pair = moments.slice(at, at + 2);

  return (
    <div>
      <MatchSelector
        value={matchId}
        onChange={(next) => {
          setFailed(false);
          setAt(0);
          setMatchId(next);
        }}
        status={
          failed
            ? "Couldn't reach StatsBomb open data."
            : loaded === undefined
              ? "Fetching events + 360 tracking from StatsBomb open data (~10 MB)…"
              : `${moments.length} tracked moments · showing ${at + 1}–${at + pair.length}`
        }
      />

      {pair.length > 0 && (
        <>
          <div className="grid gap-4 sm:grid-cols-2">
            {pair.map((moment) => (
              <MomentPitch key={moment.event.id} moment={moment} teams={loaded?.teams ?? []} />
            ))}
          </div>
          <div className="mt-3 flex gap-2">
            <button
              type="button"
              className={`px-3 font-medium hover:bg-fd-accent ${controlClass}`}
              disabled={at === 0}
              onClick={() => setAt((current) => Math.max(0, current - 2))}
            >
              ← Previous
            </button>
            <button
              type="button"
              className={`px-3 font-medium hover:bg-fd-accent ${controlClass}`}
              disabled={at + 2 >= moments.length}
              onClick={() => setAt((current) => Math.min(moments.length - 1, current + 2))}
            >
              Next →
            </button>
          </div>
        </>
      )}
    </div>
  );
}
```

## Loading and joining

Two files, one key: a frame's `event_uuid` is the matching event's `id`.

```ts
import {
  fetchMatchEvents,
  fetchMatchThreeSixty,
  indexThreeSixtyByEvent,
} from "@pitchkit/data-providers/statsbomb";

const [events, frames] = await Promise.all([
  fetchMatchEvents(3943043),
  fetchMatchThreeSixty(3943043),
]);

const frameByEvent = indexThreeSixtyByEvent(frames);
const frame = frameByEvent.get(someEvent.id); // undefined if this event wasn't tracked
```

`indexThreeSixtyByEvent` builds a `Map` once so lookups are cheap — much better than
`.find()`-ing the frames array per event when you're walking a whole match.

<Callout type="warn">
  **Check availability before fetching.** Most matches have no 360 data at all. A competition
  having *some* coverage doesn't mean every match in it does, so test the per-match field
  rather than reacting to a 404:

  ```ts
  const matches = await fetchMatches(55, 282);
  const tracked = matches.filter((m) => m.match_status_360 === "available");
  ```

  360 files are also bigger than events — around 7 MB against 3 MB.
</Callout>

Coverage within a tracked match isn't total either. It varies by match — 85% of events in one
sampled World Cup fixture — so treat a missing frame as normal, not exceptional.

## What's in a frame

```ts
interface StatsBombThreeSixtyFrame {
  event_uuid: string;
  visible_area: number[]; // flat [x0, y0, x1, y1, ...] camera polygon
  freeze_frame: StatsBombThreeSixtyPlayer[];
}

interface StatsBombThreeSixtyPlayer {
  teammate: boolean; // relative to the event's own team
  actor: boolean; // the player performing the event
  keeper: boolean;
  location: number[];
  x: number; // lifted from location, for accessors
  y: number;
}
```

**A tracked player has no identity** — no name, no id, no shirt number. 360 is optical
tracking, not event annotation, so all you get is which side they're on relative to the acting
player. That's the source data, and the types don't pretend otherwise.

Selectors read a frame's players:

```ts
import { teammatesIn, opponentsIn, actorIn, keeperIn } from "@pitchkit/data-providers/statsbomb";

teammatesIn(frame); // the acting player's side, including the actor
opponentsIn(frame); // the other side
actorIn(frame); // whoever performed the event
keeperIn(frame); // the tracked keeper, if one was in view
```

with `isTeammate` / `isOpponent` / `isActor` / `isKeeper` as the underlying predicates if you'd
rather filter yourself.

## Plotting a frame

Any layer that takes points works directly, because `x`/`y` are already lifted:

```tsx
<Pitch type="statsbomb">
  <Voronoi data={frame.freeze_frame} x={(p) => p.x} y={(p) => p.y} />
  <Scatter data={frame.freeze_frame} x={(p) => p.x} y={(p) => p.y} r={3.5} />
</Pitch>
```

`visible_area` is the pitch region the camera actually covered — the `freeze_frame` only lists
players inside it. It arrives in StatsBomb's flat encoding, so there's a helper to pair it up
for a [`<Polygon>`](/docs/overlays/polygon):

```tsx
import { visibleAreaPolygon } from "@pitchkit/data-providers/statsbomb";

<Polygon data={[frame]} points={(f) => visibleAreaPolygon(f)} fill="none" stroke="white" />;
```

### Keeping team colours stable

One thing to watch when you show more than one frame: `teammate` is relative to **whoever
performed that specific event**. Colour straight off it and the two sides swap palettes on
every change of possession, so the same colour means different teams on adjacent pitches.

Resolve it against the event's own team once, and a colour means one team throughout:

```ts
function realTeamOf(player, event, teams) {
  if (player.teammate) return event.team.name;
  return teams.find((name) => name !== event.team.name);
}
```

That's exactly what the example above does — its full source is in the **View Code** panel.

## Official documentation

* **[Open Data 360 Frames v1.0.0 (PDF)](https://github.com/statsbomb/open-data/blob/master/doc/Open%20Data%20360%20Frames%20v1.0.0%20%281%29.pdf)**
  — StatsBomb's specification for freeze frames and `visible_area`. (The `%281%29` is the
  literal `(1)` in StatsBomb's own filename, percent-encoded so the link parser can't end the
  URL on it.)
* **[Open Data Specification v1.1 (PDF)](https://github.com/statsbomb/open-data/blob/master/doc/StatsBomb%20Open%20Data%20Specification%20v1.1.pdf)**
  — the file layout these frames sit in.
* **[statsbomb/open-data](https://github.com/statsbomb/open-data)** — the repository, and the
  [`doc/`](https://github.com/statsbomb/open-data/tree/master/doc) folder holding every spec.
* **[StatsBomb free data hub](https://statsbomb.com/what-we-do/hub/free-data/)** and the
  [usage terms](https://statsbomb.com/what-we-do/hub/free-data/free-data-usage-terms/) — using
  this data obliges you to credit StatsBomb.
