# Tracking

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

> Broadcast tracking at 10 fps — streamed out of a 90 MB file, and plotted in SkillCorner's own metres.

SkillCorner's tracking is **broadcast** tracking: computer vision run over the TV feed, giving
every visible player's position ten times a second. The example below streams half a minute of
a real match and plays it back in real time.

```tsx
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Pitch, Scatter, Voronoi } from "@pitchkit/react";
import { fetchMatch, fetchMatches, streamTracking } from "@pitchkit/data-providers/skillcorner";
import type {
  SkillCornerFrame,
  SkillCornerMatch,
  SkillCornerMatchSummary,
} from "@pitchkit/data-providers/skillcorner";
import { DEFAULT_MATCH_ID, matchLabel, selectClass } from "./skillcorner-live";

/** 10 fps is the data's own rate, so this plays back in real time. */
const FPS = 10;
/** 30 seconds of football — enough for a phase of play, ~2 MB of a 90 MB file. */
const CLIP_FRAMES = 300;

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

interface Clip {
  readonly match: SkillCornerMatch;
  readonly frames: readonly SkillCornerFrame[];
  /** player_id → true when that player is on the home team. */
  readonly isHome: ReadonlyMap<number, boolean>;
}

/**
 * Stream a clip out of a match's tracking file.
 *
 * A full file is ~90 MB at 10 fps. `streamTracking` is an async generator, so
 * leaving the loop closes the reader and **aborts the download** — this pulls
 * roughly 2 MB and stops. That is the whole trick, and it's why tracking data
 * is usable in a browser at all.
 */
async function streamClip(matchId: number, signal: AbortSignal): Promise<Clip> {
  const match = await fetchMatch(matchId, { signal });
  const frames: SkillCornerFrame[] = [];

  for await (const frame of streamTracking(match, { signal })) {
    // Before kickoff every field is null and `player_data` is empty — the
    // file's own shape, not a parse failure.
    if (frame.period === null || frame.player_data.length === 0) continue;
    frames.push(frame);
    if (frames.length >= CLIP_FRAMES) break; // ← stops the download
  }

  // Tracking carries only `player_id` — no name, no team — so the match file
  // is what turns a position into a side. The join key is `players[].id`,
  // **not** `trackable_object`, which is a different id space entirely.
  const isHome = new Map(
    match.players.map((player) => [player.id, player.team_id === match.home_team.id]),
  );

  return { match, frames, isHome };
}

/** Loads a clip whenever the chosen match changes, and cancels the last one. */
function useClip(matchId: number) {
  const [clip, setClip] = useState<{ key: number; value: Clip } | undefined>();
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    streamClip(matchId, controller.signal)
      .then((value) => setClip({ key: matchId, value }))
      .catch(() => {
        if (!controller.signal.aborted) setFailed(true);
      });
    return () => controller.abort();
  }, [matchId]);

  return { clip: clip?.key === matchId ? clip.value : undefined, failed };
}

/** Advances a frame index at a fixed 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));
}

/** Match picker and status line — the chrome, kept out of the way. */
function MatchPicker({
  value,
  onChange,
  status,
}: {
  value: number;
  onChange: (id: number) => void;
  status: ReactNode;
}) {
  const [matches, setMatches] = useState<SkillCornerMatchSummary[]>([]);

  useEffect(() => {
    fetchMatches()
      .then(setMatches)
      .catch(() => undefined);
  }, []);

  return (
    <>
      <select
        aria-label="SkillCorner match"
        value={value}
        disabled={matches.length === 0}
        onChange={(event) => onChange(Number(event.target.value))}
        className={selectClass}
      >
        {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>}
        {matches.map((match) => (
          <option key={match.id} value={match.id}>
            {matchLabel(match)}
          </option>
        ))}
      </select>
      <p className="my-3 text-xs text-fd-muted-foreground">{status}</p>
    </>
  );
}

/**
 * A streamed clip of SkillCorner broadcast tracking, playing at 10 fps.
 *
 * Coordinates go in **raw**: `<Pitch type="skillcorner">` uses SkillCorner's
 * own centre-origin metres, so the accessors are just `(p) => p.x`. The
 * `dimensions` prop draws this stadium's real pitch — they run 104 to 106 m.
 */
export function SkillcornerTrackingBasic() {
  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);
  const { clip, failed } = useClip(matchId);
  const at = usePlayhead(clip?.frames.length ?? 0);
  const frame = clip?.frames[at];

  const fill = (player: { player_id: number }) =>
    clip?.isHome.get(player.player_id) ? TEAM_COLORS[0] : TEAM_COLORS[1];

  return (
    <div>
      <MatchPicker
        value={matchId}
        onChange={setMatchId}
        status={
          failed
            ? "Couldn't reach SkillCorner open data."
            : clip === undefined
              ? `Streaming ${CLIP_FRAMES} frames out of a ~90 MB tracking file…`
              : `${clip.frames.length} frames · ${clip.match.pitch_length}×${clip.match.pitch_width} m pitch · playing at ${FPS} fps`
        }
      />

      <Pitch
        type="skillcorner"
        dimensions={clip && { length: clip.match.pitch_length, width: clip.match.pitch_width }}
      >
        {frame && (
          <>
            <Voronoi
              data={frame.player_data}
              x={(player) => player.x}
              y={(player) => player.y}
              fill={fill}
              fillOpacity={0.13}
              stroke="rgba(255,255,255,0.18)"
              strokeWidth={0.4}
            />
            <Scatter
              data={frame.player_data}
              x={(player) => player.x}
              y={(player) => player.y}
              r={2.4}
              fill={fill}
              // Broadcast tracking only sees what the camera framed; the rest
              // is extrapolated between sightings, and `is_detected` says which.
              fillOpacity={(player) => (player.is_detected ? 1 : 0.25)}
              stroke={fill}
              strokeWidth={0.7}
            />
            {frame.ball_data.x !== null && frame.ball_data.y !== null && (
              <Scatter
                data={[frame.ball_data]}
                x={(ball) => ball.x ?? 0}
                y={(ball) => ball.y ?? 0}
                r={1.4}
                fill="#fff"
                stroke="#111"
                strokeWidth={0.4}
              />
            )}
          </>
        )}
      </Pitch>
    </div>
  );
}
```

## The size problem, and the answer

One match's tracking file is about **90 MB**. Downloading it to draw thirty seconds would be
absurd, so `streamTracking` is an **async generator** — leaving the loop closes the reader,
which aborts the response mid-download:

```ts
import { fetchMatch, streamTracking } from "@pitchkit/data-providers/skillcorner";

const match = await fetchMatch(1874553);
const frames = [];

for await (const frame of streamTracking(match)) {
  if (frame.period === null || frame.player_data.length === 0) continue;
  frames.push(frame);
  if (frames.length >= 300) break; // ← stops the download
}
```

Measured in a browser against the real file: &#x2A;*1.9 MB of 86.5 MB, 2.2%**. If you'd rather jump
into the middle of a match, `fetchTrackingWindow(match, { fromFrame, toFrame })` does an HTTP
`Range` read instead, estimating the byte offset and filtering to the frames you asked for.

<Callout type="warn">
  **Tracking is served from a different host.** These files are stored with Git LFS, so
  `raw.githubusercontent.com` returns a \~130-byte pointer stub rather than data — which fails as
  "not valid JSON" on a file that looks perfectly fine in a browser. The loaders already point at
  `media.githubusercontent.com` for tracking and the raw host for everything else. If you mirror the
  data yourself, `{baseUrl}` and `{lfsBaseUrl}` are separate options for this reason.
</Callout>

## Plotting it

Coordinates go in **raw**. SkillCorner measures in metres from the centre spot, and
`<Pitch type="skillcorner">` uses that same grid, so the accessors are just `(p) => p.x`:

```tsx
<Pitch type="skillcorner" dimensions={{ length: match.pitch_length, width: match.pitch_width }}>
  <Voronoi data={frame.player_data} x={(p) => p.x} y={(p) => p.y} />
  <Scatter data={frame.player_data} x={(p) => p.x} y={(p) => p.y} r={2.4} />
</Pitch>
```

`dimensions` is worth passing: SkillCorner pitches are real stadium pitches, and the open data
spans **104, 105 and 106 m**. Markings don't scale with it — a penalty area is 16.5 m deep on
any pitch — so only the outline, halfway line and goal lines move.

## What's in a frame

```ts
interface SkillCornerFrame {
  frame: number;
  timestamp: string | null; // "00:43:32.00", null before kickoff
  period: number | null;
  ball_data: { x: number | null; y: number | null; z: number | null; is_detected: boolean | null };
  possession: { player_id: number | null; group: string | null };
  player_data: SkillCornerTrackedPlayer[];
}

interface SkillCornerTrackedPlayer {
  player_id: number;
  x: number; // metres from the centre spot
  y: number;
  is_detected: boolean; // false = extrapolated, not seen
  pitchX: number; // corner-origin metres, if your chart wants them
  pitchY: number;
}
```

Frames before kickoff carry nulls throughout with an empty `player_data`. That's the file's own
shape, not a parse failure — skip them rather than treating them as an error.

### `is_detected` is the field to respect

The broadcast camera only frames part of the pitch. Players outside the shot can't be seen, so
SkillCorner **estimates** where they are — the file is called `tracking_extrapolated` for a
reason. Measured across 120 in-play frames of one match: **55% of positions were genuinely
detected**, and **no frame had all 22 players visible** — the best had 19, the worst 8.

That matters downstream. Distance covered, whether a player was onside, the shape of a Voronoi
cell for someone off-camera — all inherit the estimate. The example above fades undetected
markers rather than drawing them identically.

It's the opposite choice to [StatsBomb 360](/docs/data/statsbomb/360), which lists only players
inside the camera's `visible_area` and omits the rest. SkillCorner fills the gaps and flags
them; StatsBomb leaves the gaps. Neither is wrong — but you need to know which you're holding.

## Joining a position to a player

Tracking carries only `player_id` — no name, no team. The match file is the lookup:

```ts
import { indexPlayersById } from "@pitchkit/data-providers/skillcorner";

const players = indexPlayersById(match);
const player = players.get(tracked.player_id); // name, team_id, shirt number, role
```

<Callout type="warn">
  The join key is `players[].id`, **not** `trackable_object`. They're different id spaces, and
  `trackable_object` matches nothing in the tracking file — an easy hour to lose.
</Callout>

## Which way is the team attacking?

**Tracking coordinates are absolute**, so a team's x flips sign at half time. This is the
opposite of the [dynamic events](/docs/data/skillcorner/dynamic-events) file, whose x is
normalised to the attacking direction. Mixing them up mirrors half a match silently.

`attackingSideOf` resolves it from the match's own `home_team_side`:

```ts
import { attackingSideOf } from "@pitchkit/data-providers/skillcorner";

attackingSideOf(match, teamId, period); // "left_to_right" | "right_to_left" | undefined
```

## Official documentation

This page describes how PitchKit loads the data. For the data itself, SkillCorner's own
documentation is the authority:

* **[SkillCorner Open Data docs](https://skillcorner.github.io/opendata/)** — the tracking
  format, coordinate system and field definitions, first-hand.
* **[SkillCorner/opendata](https://github.com/SkillCorner/opendata)** — the repository these
  loaders fetch from, including the Jupyter tutorials in
  [`notebooks/tutorials`](https://github.com/SkillCorner/opendata/tree/master/notebooks/tutorials).
* **[skillcorner.com](https://skillcorner.com/)** — the company behind the data.

The open data is MIT-licensed and SkillCorner ask to be credited in anything you publish from
it.

## Next

[Dynamic events](/docs/data/skillcorner/dynamic-events) — SkillCorner's derived model of
possessions, passing options, off-ball runs and pressure, sharing this file's frame counter.
