# Introduction Source: https://www.pitchkitjs.com/docs > What PitchKit is, and how the pieces fit together. PitchKit is a React-first football pitch visualisation library for the web — mplsoccer's surface, built for the browser. `@pitchkit/core` is a framework-agnostic engine (coordinate transforms, SVG + Canvas rendering); `@pitchkit/react` is the officially supported set of declarative bindings on top of it, split into two families: **components** (the pitch itself) and **overlays** (data plotted onto it). ## Install ```bash npm install @pitchkit/core @pitchkit/react ``` ## The smallest thing that works ```tsx "use client"; import { Pitch, Scatter } from "@pitchkit/react"; const shots = [ { x: 108, y: 38, xg: 0.62 }, { x: 96, y: 48, xg: 0.15 }, ]; export function ShotMap() { return ( s.x} y={(s) => s.y} r={(s) => 3 + s.xg * 9} /> ); } ``` That's a responsive, server-renderable shot map. Every layer takes plain data plus **accessor functions** for its visual properties — `x`, `y`, and most other props accept either a static value or a per-datum function, which is how PitchKit stays shape-agnostic across providers (StatsBomb, Opta, UEFA, or your own). For the same thing built up step by step against a real match — loading the Euro 2024 final, finding the move that produced England's goal, and plotting it — start with the **[Quickstart](/docs/quickstart)**. ## Finding your way around * **[Quickstart](/docs/quickstart)** — the guided build: a real match in, a real passage of play out, in four steps. * **[Guides](/docs/guides/coordinates)** — the concepts: coordinates & pitch types, layers, responsive behaviour, and Next.js/SSR. * **[Components](/docs/components/pitch)** — the pitch container that owns the coordinate system: `` and ``. * **Overlays** — layers stacked inside a ``, one page per visual, each with a live example: [Scatter](/docs/overlays/scatter), [Arrows](/docs/overlays/arrows), [Comet](/docs/overlays/comet), [Annotate](/docs/overlays/annotate), [Heatmap](/docs/overlays/heatmap), [Flow](/docs/overlays/flow), [Polygon](/docs/overlays/polygon), [Convex Hull](/docs/overlays/convex-hull), [Voronoi](/docs/overlays/voronoi), and [Goal Angle](/docs/overlays/goal-angle). * **[Agents](/docs/agents)** — building with AI: `llms.txt`, and the [Agent Skill](/docs/agents/skills) that ships inside `@pitchkit/react`. * **[Styling](/docs/styling/theming)** — [theming](/docs/styling/theming) with CSS variables, [Tailwind](/docs/styling/tailwind) utilities, and [pitch palettes](/docs/styling/palettes). * **[Data](/docs/data)** — loading real open football data in one call with the optional `@pitchkit/data-providers` package: StatsBomb [events](/docs/data/statsbomb/events) and [360 tracking](/docs/data/statsbomb/360), SkillCorner [tracking](/docs/data/skillcorner/tracking), and [Wyscout events](/docs/data/wyscout/events) — every provider page with a live example fetching a real match. * **[mplsoccer → PitchKit](/docs/migration)** — the migration cheatsheet. * **[API Reference](/docs/api)** — every public export, generated from TSDoc. * **[Gallery](/gallery)** — finished visualisations with full source and sandbox exports. ## Reading these docs with an AI agent No model has PitchKit in its training data, so anything an agent "remembers" about the API is invented. Three ways to give it the real thing, cheapest first: * **[`/llms.txt`](https://www.pitchkitjs.com/llms.txt)** — an index of every page on this site, in the [llms.txt](https://llmstxt.org) format. Paste the URL into a prompt. * **[`/llms-full.txt`](https://www.pitchkitjs.com/llms-full.txt)** — every guide, component and overlay page as one Markdown document, examples inlined. Small enough to hand over whole. The generated API reference is kept separate as [`/llms-api.txt`](https://www.pitchkitjs.com/llms-api.txt) so it doesn't crowd out the prose. * **[The Agent Skill](/docs/agents/skills)** — the best option if you're already installing the package, because it's versioned with the code rather than scraped from here. Any page on this site is also available as raw Markdown: append `.md` to its URL (for example [`/docs/styling/theming.md`](/docs/styling/theming.md)). --- # mplsoccer → PitchKit Source: https://www.pitchkitjs.com/docs/migration > The cheatsheet — every common mplsoccer call and its PitchKit equivalent. PitchKit is deliberately "mplsoccer for the web": the concepts map one-to-one, so most of migrating is translating syntax, not relearning ideas. The real shifts are structural — and there are only three: 1. **A React tree instead of a matplotlib figure.** No `fig, ax = pitch.draw()`; the pitch is a component and marks are its children. 2. **Accessors instead of column arrays.** mplsoccer takes parallel arrays (`pitch.scatter(df.x, df.y)`); PitchKit takes your records once plus a function per visual property (` s.x} />`). 3. **CSS instead of kwargs for colour.** `pitch_color=`/`line_color=` become the [`--pitch-*` variables](/docs/styling/theming); per-mark colour stays inline via accessor props. ## Pitch setup | mplsoccer | PitchKit | | ----------------------------------------- | ------------------------------------------------------------------------ | | `Pitch(pitch_type="statsbomb")` | `` | | `VerticalPitch(...)` | `` | | `Pitch(half=True)` | `crop={cropForHalf(getPitchDimensions("statsbomb"))}` | | `Pitch(pitch_color="grass", stripe=True)` | `--pitch-surface` variable + `appearance={{ stripes: true }}` | | `Pitch(line_color="white", linewidth=2)` | `--pitch-lines` / `--pitch-line-width` variables | | `Pitch(goal_type="box")` | `appearance={{ goalType: "box" }}` | | `Pitch(line_zorder=2)` | `appearance={{ linesOnTop: true }}` | | `Pitch(pad_left=10, ...)` | `padding={{ left: 10, ... }}` (pixels) | | `fig, ax = pitch.draw(figsize=(8, 5))` | Not needed — responsive by default; `width`/`height` props to fix pixels | ## Plotting | mplsoccer | PitchKit | | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `pitch.scatter(x, y, s=size, ax=ax)` | `` | | `pitch.arrows(xstart, ystart, xend, yend)` | `` | | `pitch.lines(..., comet=True, transparent=True)` | `` | | `pitch.annotate(text, xy=(x, y))` | `` | | `pitch.bin_statistic(...)` + `pitch.heatmap(...)` | `` | | `pitch.bin_statistic_positional(...)` + `pitch.heatmap_positional(...)` | `` | | `pitch.hexbin(x, y, gridsize=...)` | `` | | `pitch.kdeplot(x, y, bw_adjust=...)` | `` | | `pitch.flow(xstart, ystart, xend, yend, bins=...)` | `` | | `pitch.polygon(verts)` | `` | | `pitch.convexhull(x, y)` + `pitch.polygon` | `` | | `pitch.voronoi(x, y, teams)` | ` teamColor(p)} />` | | `pitch.goal_angle(x, y)` | `` | Binning for ``/`` happens inside the component — there's no separate `bin_statistic` step to manage. ## Utilities | mplsoccer | PitchKit | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `Standardizer(pitch_from="opta", pitch_to="statsbomb")` | `createStandardizeTransform(getPitchDimensions("opta"), getPitchDimensions("statsbomb"))` | | Pitch dimension constants | `getPitchDimensions(type)` / `PITCH_DIMENSIONS` | | `FontManager`, custom fonts | Plain CSS — pitches inherit your page's fonts | One caveat on standardizing: PitchKit's current transform is a uniform extent mapping. mplsoccer's `Standardizer` interpolates between pitch *markings* as control points, so marking-boundary alignment across providers can differ slightly — exact for corners and the centre spot, approximate at box edges. ## Not ported (yet) Radars, bumpy charts, and pizza charts are Milestone 2 scope — the [roadmap issues](https://github.com/yribeiro/pitchkit/issues) track each one. Everything in the tables above works today; see the [gallery](/gallery) for finished equivalents of mplsoccer's own example charts. ## A worked example mplsoccer: ```python pitch = VerticalPitch(pitch_type="statsbomb", half=True) fig, ax = pitch.draw() pitch.scatter(df.x, df.y, s=df.xg * 500, c="#38bdf8", ax=ax) ``` PitchKit: ```tsx const dims = getPitchDimensions("statsbomb"); s.x} y={(s) => s.y} r={(s) => 3 + s.xg * 9} fill="#38bdf8" /> ; ``` --- # Quickstart Source: https://www.pitchkitjs.com/docs/quickstart > Build a real chart from real data — the move that produced England's goal in the Euro 2024 final. By the end of this page you'll have plotted a real passage of play from a real match: the move that produced England's goal in the **Euro 2024 final** — Pickford's throw out, Saka driving a third of the pitch up the right, the cutback, and Cole Palmer's finish. [Cole Palmer equalises for England — Spain v England, Euro 2024 final](https://www.youtube.com/watch?v=lBOS43RWfY0&t=315s) (video) Nothing here is hardcoded. The match arrives over the network, the move is found by filtering it, and the chart is a stack of PitchKit layers over the result. ```tsx import { useEffect, useState } from "react"; import { Annotate, Arrows, Comet, Pitch, Scatter } from "@pitchkit/react"; import { fetchMatchEvents, isCarry, isGoal, isPass, shots, } from "@pitchkit/data-providers/statsbomb"; import type { StatsBombCarry, StatsBombEvent, StatsBombPass, StatsBombShot, } from "@pitchkit/data-providers/statsbomb"; /** Euro 2024 final — Spain 2–1 England, Berlin, 14 July 2024. */ const EURO_2024_FINAL = 3943043; interface Chain { passes: StatsBombPass[]; carries: StatsBombCarry[]; goal: StatsBombShot; } /** * The possession that produced England's goal, cut off at the goal itself. * * StatsBomb stamps every event with a `possession` number and the team that * owned it, so the move is a filter rather than a reconstruction. Two * details do the real work: * * - A possession does **not** end at the shot — it runs on until the ball * changes hands, so it has to be truncated at the goal itself. * - A possession contains the *other* team's events too (pressures, blocks, * an interception that didn't stick), so it's filtered down to the team * that owned it. */ function goalChain(events: StatsBombEvent[]): Chain | undefined { const goal = shots(events) .filter(isGoal) .find((shot) => shot.team.name === "England"); if (!goal) return undefined; const possession = events.filter((event) => event.possession === goal.possession); const upToGoal = possession.slice(0, possession.indexOf(goal) + 1); const attacking = upToGoal.filter((event) => event.team.id === event.possession_team.id); return { passes: attacking.filter(isPass), // A unit or two is a touch adjustment, not progression — and it // renders as a speck rather than a trail. (StatsBomb x/y are abstract // units on a 120 x 80 grid, not metres.) carries: attacking .filter(isCarry) .filter((carry) => Math.hypot(carry.endX - carry.x, carry.endY - carry.y) > 2), goal, }; } /** * The quickstart's finished chart: load a real match, isolate the move that * produced a goal, and plot it — passes as arrows, carries as comet trails, * the goal as a labelled marker. */ export function QuickstartChainBasic() { const [chain, setChain] = useState(); const [failed, setFailed] = useState(false); useEffect(() => { fetchMatchEvents(EURO_2024_FINAL) .then((events) => setChain(goalChain(events))) .catch(() => setFailed(true)); }, []); return (

{failed ? "Couldn't reach StatsBomb open data." : chain === undefined ? "Fetching Spain 2–1 England from StatsBomb open data (~3 MB)…" : `${chain.passes.length} passes · ${chain.carries.length} ${chain.carries.length === 1 ? "carry" : "carries"} · ${chain.goal.player?.name ?? "Unknown"}, ${chain.goal.minute}'`}

carry.x} y={(carry) => carry.y} x2={(carry) => carry.endX} y2={(carry) => carry.endY} gradient endWidth={5} tooltip={(carry) => `${carry.player?.name ?? "Unknown"} — carry`} /> pass.x} y={(pass) => pass.y} x2={(pass) => pass.endX} y2={(pass) => pass.endY} strokeWidth={2} strokeOpacity={0.85} tooltip={(pass) => `${pass.player?.name ?? "Unknown"} — pass`} /> pass.x} y={(pass) => pass.y} r={3} stroke="white" strokeWidth={1.5} tooltip={(pass) => pass.player?.name ?? "Unknown"} /> goal.x} y={(goal) => goal.y} r={6} fill="var(--pitch-marker-goal)" stroke="white" strokeWidth={2} tooltip={(goal) => `${goal.player?.name ?? "Unknown"} — goal, ${goal.shot.statsbomb_xg.toFixed(2)} xG` } /> goal.x} y={(goal) => goal.y} label={(goal) => `Goal · ${goal.shot.statsbomb_xg.toFixed(2)} xG`} offsetY={-12} />
); } ``` ## Install `react` has the components you render, `core` has the coordinate transforms underneath them, and `data-providers` is a convenience library for handling data. ```bash npm install @pitchkit/core @pitchkit/react @pitchkit/data-providers ``` ## Step 1 — load a match The Euro 2024 final is `3943043`; every fixture is discoverable through `fetchCompetitions` and `fetchMatches` (see [Events](/docs/data/statsbomb/events)). ```ts import { fetchMatchEvents } from "@pitchkit/data-providers/statsbomb"; const events = await fetchMatchEvents(3943043); // Spain 2–1 England ``` `events` is \~3,500 StatsBomb events in StatsBomb's own shape — nothing is renamed. The one addition is that `location` arrays are lifted to `x` / `y` (and `end_location` to `endX` / `endY`), so an accessor can read them directly. A match file is roughly 3 MB. Fetch it once and cache it — don't call `fetchMatchEvents` inside a render. ## Step 2 — draw a pitch `` owns the coordinate system. Tell it which provider's grid your numbers are on and it does the rest — StatsBomb's is 120 × 80, so nothing needs normalising. It's responsive by default: it fills its container and keeps the pitch's aspect ratio. Passing explicit `width`/`height` is available but not necessary. ```tsx import { Pitch } from "@pitchkit/react"; ; ``` ## Step 3 — find the move This is the part that's actually football rather than plumbing. Start with the goal. `shots()` narrows the feed to shots, `isGoal` is an ordinary predicate, so finding it is one chain of array methods: ```ts import { isCarry, isGoal, isPass, shots } from "@pitchkit/data-providers/statsbomb"; const goal = shots(events) .filter(isGoal) .find((shot) => shot.team.name === "England"); if (!goal) return; ``` Now work backwards. StatsBomb stamps every event with a `possession` number and the team that owned it, so "the move that led to this goal" is a filter: ```ts const possession = events.filter((event) => event.possession === goal.possession); const upToGoal = possession.slice(0, possession.indexOf(goal) + 1); const attacking = upToGoal.filter((event) => event.team.id === event.possession_team.id); const passes = attacking.filter(isPass); const carries = attacking.filter(isCarry); ``` Two details there are easy to get wrong: * **A possession doesn't end at the shot.** It runs on until the ball genuinely changes hands. A goal is the tidy case — this possession ends on Palmer's finish, so the `slice` costs nothing. Point the same code at a blocked shot or a save and the possession carries on through the rebound, and without the `slice` you'd draw all of it. * **A possession contains the other team's events.** Pressures, blocks, an interception that didn't stick are all stamped with the same `possession` number. Comparing `team` against `possession_team` keeps the chain to the side building it. ## Step 4 — plot the move Layers stack inside the ``, in paint order. Each one takes plain data plus **accessor functions**: ```tsx import { Arrows, Comet, Pitch, Scatter } from "@pitchkit/react"; carry.x} y={(carry) => carry.y} x2={(carry) => carry.endX} y2={(carry) => carry.endY} gradient /> pass.x} y={(pass) => pass.y} x2={(pass) => pass.endX} y2={(pass) => pass.endY} /> g.x} y={(g) => g.y} r={6} tooltip={(g) => `${g.player?.name} — goal, ${g.shot.statsbomb_xg.toFixed(2)} xG`} /> ; ``` Saka's carry up the right is the one comet on the chart, and it's why carries are worth drawing separately: as an arrow it would be indistinguishable from the long pass that started the move. Most props accept a static value *or* a per-datum function, so styling is data-driven without a separate encoding step: ```tsx s.x} y={(s) => s.y} r={(s) => 3 + s.shot.statsbomb_xg * 9} /> ``` ## The finished chart That's the example at the top of this page: five passes and one carry, goalkeeper to goal. Pickford throws to Bellingham, Bellingham to Palmer, Palmer wide to Saka, Saka carries to the byline and cuts it back, Bellingham bounces it into Palmer's path, and Palmer finishes from the edge of the box at 0.04 xG. Expand **[View Code](#quickstart-chain-basic)** on the preview for the complete component, including the loading states and the carry filter. ## Where to go next * **[Coordinates & pitch types](/docs/guides/coordinates)** — what `type="statsbomb"` actually sets, and how to plot Opta, UEFA, SkillCorner or your own grid. * **[Overlays](/docs/overlays/scatter)** — every layer, one page each, with a live example. * **[Data](/docs/data)** — the rest of `@pitchkit/data-providers`: StatsBomb [360 tracking](/docs/data/statsbomb/360) and [SkillCorner](/docs/data/skillcorner/tracking). * **[Theming](/docs/styling/theming)** — the pitch is styled with CSS variables, so it inherits your app's look. * **[Build with AI](/docs/agents)** — hand an agent the real API instead of letting it guess. --- # Overview Source: https://www.pitchkitjs.com/docs/agents > Build PitchKit charts with a coding agent — prompts to paste, a skill to install, and docs written for models. ## Prompts Paste one of these. Each is self-contained — the agent fetches what it needs. **Set up a new project:** ```text Build me a football data visualisation app using PitchKit. First, read https://www.pitchkitjs.com/llms.txt and follow the links you need from it. PitchKit is not in your training data, so do not rely on what you remember about it — everything you need is in those docs. Scaffold a Next.js app, install @pitchkit/core, @pitchkit/react and @pitchkit/data-providers, then build a shot map from a real StatsBomb match. ``` **Add PitchKit to a project you already have:** ```text Add PitchKit to this project to render football pitch visualisations. Read https://www.pitchkitjs.com/llms.txt first and follow the links from it — PitchKit is not in your training data, so anything you recall about its API is invented. Then install it, and after installing run `npx @pitchkit/react skills install` so you keep the API reference alongside the version we're on. ``` **Build one specific chart:** ```text Using PitchKit, build a pass network for this data: Read https://www.pitchkitjs.com/llms-full.txt first for the real API — PitchKit post-dates your training data. Match the coordinate system to my data's provider rather than converting the numbers. ``` The common thread is the first instruction. Telling a model *not* to trust its recollection is what stops it pattern-matching to mplsoccer, and it matters more than which of the three sources you point it at. ## What PitchKit gives an agent | | What it is | Best for | | -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | **[Agent Skill](/docs/agents/skills)** | Ships inside `@pitchkit/react`; `npx @pitchkit/react skills install` symlinks it into your agent | Anything where you've already installed the package | | **[llms.txt](/docs/agents/llms-txt)** | This whole site as Markdown, at a URL | First contact, and tools that can't read `node_modules` | | **Per-page Markdown** | Append `.md` to any docs URL | Pointing an agent at one specific thing | | **Typed API** | Full TypeScript types on every export | Catching the mistakes that survive the above | ## Works with None of this is tool-specific. Every prompt above is just text, and the skill is a Markdown file in a directory — anything that can read your repository can use it. The install command writes to Claude Code's directory by default and takes `--dir` for anywhere else: ```bash npx @pitchkit/react skills install --dir .cursor/skills ``` For tools that can't see your filesystem at all — browser-based builders especially — paste the `llms.txt` URL into the prompt and skip the install. ## Why this is a first-class concern Writing software is turning into directing it. You describe what you want, review what comes back, and less of the typing is yours. That makes a library's AI tooling part of the library — because the same prompt can produce something you'd have written yourself or something plausible that doesn't compile, and what separates them is almost entirely the context the model was handed. Context is the one lever you have over a process that isn't deterministic, and PitchKit needs it more than most: nothing about the library is in any model's training data, so an agent left to guess reaches for mplsoccer's Python API and invents the JSX to match. That gap doesn't close with time either — once a breaking change ships, training data holds both versions forever with no way to tell which one you're on. So the skill is versioned with the code and installed from `node_modules`, while the hosted files cover the cases where that isn't possible — scraped docs only half-solve it, because the scrape describes the current release rather than the one in your `package.json`. None of it replaces reading the [guides](/docs/guides/coordinates) yourself. It means you shouldn't have to, to get a correct first draft out of an agent. --- # llms.txt Source: https://www.pitchkitjs.com/docs/agents/llms-txt > This whole site as Markdown, at a URL — for agents that can't read your node_modules. Every page on this site is published in [llms.txt](https://llmstxt.org) format: plain Markdown at a stable URL, no scraping required. Paste a URL into a prompt and the agent has the real API. ## The files | URL | Contents | Size | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------ | | [`/llms.txt`](https://www.pitchkitjs.com/llms.txt) | An index of every page, one line each | \~600 tokens | | [`/llms-full.txt`](https://www.pitchkitjs.com/llms-full.txt) | Every guide, component, overlay and data page as one document, code examples inlined | \~14k tokens | | [`/llms-api.txt`](https://www.pitchkitjs.com/llms-api.txt) | The generated TypeDoc reference, every symbol | \~22k tokens | Start with `/llms.txt`. It's small enough to sit in a prompt without crowding anything out, and it links to everything else — most agents will fetch what they need from it. Reach for `/llms-full.txt` when you want the whole thing in context up front and can afford \~14k tokens. The generated API reference is kept out of it deliberately: a symbol-per-page dump is 117 pages of signatures that crowd out the prose actually teaching the library. Pull `/llms-api.txt` separately if you need an exact signature. ## Any page as Markdown Append `.md` to any docs URL: ```text https://www.pitchkitjs.com/docs/styling/theming.md https://www.pitchkitjs.com/docs/overlays/scatter.md https://www.pitchkitjs.com/docs/data/statsbomb/events.md ``` Useful when you know exactly which page is relevant and don't want to spend context on the rest. The examples are inlined as real source, so the overlay pages carry working code rather than a reference to a live preview. ## The catch These describe **whatever is currently deployed** — the latest release, not the version in your `package.json`. For a library still below `1.0`, that gap is real. If you've installed the package, the [Agent Skill](/docs/agents/skills) doesn't have that problem: it's symlinked out of `node_modules`, so it moves with `npm update` and can only ever describe the version you're actually on. Use `llms.txt` for first contact and for tools that can't reach your filesystem; use the skill for everything else. ## Machine discovery The files are listed in [the sitemap](https://www.pitchkitjs.com/sitemap.xml) alongside every page and its `.md` twin, so a crawler that only reads the sitemap still finds them. --- # Skills Source: https://www.pitchkitjs.com/docs/agents/skills > The Agent Skill that ships inside @pitchkit/react — versioned with the package, not scraped from this site. `@pitchkit/react` ships an [Agent Skill](https://code.claude.com/docs/en/skills) in its npm tarball: the real API, the gotchas that break builds, and four worked recipes, installed from `node_modules` rather than fetched from here. ## Install ```bash npx @pitchkit/react skills install ``` That links `.claude/skills/pitchkit/` to the copy inside `node_modules`. For another agent, point it elsewhere: ```bash npx @pitchkit/react skills install --dir .cursor/skills ``` `npx @pitchkit/react skills path` prints where the skill lives if you'd rather wire it up yourself. ## It stays current Because it's a link, not a copy, the skill tracks whatever version you have installed: ```bash npm update @pitchkit/react # skill is already current — nothing else to run ``` That matters more than it sounds. A copied skill is correct the day you extract it and silently wrong after the next breaking change — the same version ambiguity that makes training data unreliable, one layer down. It's also the one thing [llms.txt](/docs/agents/llms-txt) can't give you, since those files describe whatever is deployed here rather than what you installed. On filesystems that reject symlinks it copies instead and tells you, in which case re-run with `--force` after upgrading. ## What's in it | Part | Covers | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | The model | The core/react split, the three provider coordinate systems, accessors, responsive sizing, CSS-variable theming | | Anti-hallucinations | The plausible things that *don't* exist — ``, a `theme` prop, `type="tracab"` | | Build-breakers | The `"use client"` boundary, density layers needing a fixed-pixel ``, `linesOnTop` | | Four recipes | Shot map, pass map, heatmap, pass network — tested against the real exports | | API reference | Every component's props, plus the `@pitchkit/core` helpers worth calling | ## How it triggers The agent reads the skill's one-line description at startup — costing nothing until it's relevant — and loads the rest only when a request matches: a shot map, a pass network, anything mentioning StatsBomb, Opta, or ``. Your prompts don't change. ## See also * [llms.txt](/docs/agents/llms-txt) — the hosted alternative, for tools that can't read `node_modules` * [Theming](/docs/styling/theming) — the CSS-variable system the skill documents * [Gallery](/gallery) — the same examples, with live previews --- # Pitch Source: https://www.pitchkitjs.com/docs/components/pitch > The root component — owns the coordinate system and is responsive by default. `` renders the pitch surface and markings, and provides the coordinate transform every layer component reads from context. It's responsive by default (fills its container via `ResizeObserver`); pass explicit `width`/`height` to opt out — required for ``, which paints to a `` with no server-rendered content to size against. ```tsx import { Pitch } from "@pitchkit/react"; /** Bare pitch, no marks — responsive by default (fills its container). */ export function PitchBasic() { return ; } ``` ## Vertical orientation `` is `` under a friendlier name (matching mplsoccer's `VerticalPitch`) — useful for shot maps and attacking-third views. ```tsx import { VerticalPitch } from "@pitchkit/react"; /** Same pitch, vertical orientation — useful for shot maps and attacking-third views. */ export function VerticalPitchBasic() { return ; } ``` ## Appearance Colours are CSS variables (`--pitch-surface`, `--pitch-stripe`, `--pitch-lines` and others) — set them once per theme, no per-instance props needed. See [Styling → Theming](/docs/styling/theming) for the full list and [Styling → Tailwind](/docs/styling/tailwind) for setting them with Tailwind utilities. `stripes`, `goalType` and `linesOnTop` are the structural knobs, passed via the `appearance` prop: ```tsx ``` | Key | Does | | ------------ | -------------------------------------------------------------------------------------- | | `stripes` | `true` for a sensible default count, or a number for exactly that many mown bands | | `goalType` | `"line"` (default) or `"box"` | | `linesOnTop` | Paints the markings above the layers instead of below them — mplsoccer's `line_zorder` | `linesOnTop` is off by default so discrete marks sit on top of the lines. Turn it on for the density layers ([``](/docs/overlays/heatmap), [``](/docs/overlays/positional-heatmap), [``](/docs/overlays/hexbin), [``](/docs/overlays/kde)), whose fills otherwise cover the markings underneath them. Only the markings move — the grass surface and stripes always stay at the bottom. --- # Overview Source: https://www.pitchkitjs.com/docs/data > Load real open football data in one call — and when not to bother. `@pitchkit/data-providers` takes you from a match id to chart-ready data without a pipeline in between. It's a separate, optional package with no dependency on `@pitchkit/core` or `@pitchkit/react` — its only runtime dependency is `csv-parse`, for SkillCorner's CSV files. ```bash npm install @pitchkit/data-providers ``` ```tsx import { fetchMatchEvents, shots, isGoal } from "@pitchkit/data-providers/statsbomb"; import { Scatter, VerticalPitch } from "@pitchkit/react"; const events = await fetchMatchEvents(3943043); // Spain 2–1 England, Euro 2024 final const spain = shots(events).filter((shot) => shot.team.name === "Spain"); s.x} y={(s) => s.y} fill={(s) => (isGoal(s) ? "orange" : "steelblue")} /> ; ``` That's the whole path. No adapter, no field mapping, no coordinate conversion. ## You probably don't need this PitchKit's layers take **accessor functions**, so they already read whatever shape your data is in — that's the point of the [coordinates guide](/docs/guides/coordinates). If you have your own pipeline, point `x`/`y` at your own fields and skip this package entirely. Nothing in `@pitchkit/react` depends on it. It exists for the cases where fetching real data is the friction: tutorials, prototypes, learning the API, and agent-assisted builds where a complete runnable example beats a schema description. ## How the loaders are shaped Four layers, each usable on its own: | Layer | What it does | | ------------------ | ---------------------------------------------------------------------------------------------- | | `parse*` | Pure — already-loaded JSON in, typed objects out. No network. | | `load*` / `fetch*` | Fetch and parse in one call. `load*` takes any URL; `fetch*` builds the open-data URL for you. | | Selectors | Narrow a mixed feed: `shots()`, `passes()`, `carries()`, `ofType()`. | | Predicates | Compose with `.filter()`: `isGoal`, `isComplete`, `isCorner`, `isCross`, … | Two principles run through all of it, and they're worth knowing before you read the provider pages: **The provider's data stays the provider's.** Field names keep their original spelling, values keep their original strings — a StatsBomb outcome is `"Off T"`, not a re-spelled `"off-target"`. You can read StatsBomb's own spec alongside these types with no mapping table in between. The one exception is coordinates: `location` arrays are *also* surfaced as `x`/`y`/`endX`/`endY`, because that's what a PitchKit accessor wants. **Interpretation lives in functions, not fields.** There's no invented `complete: boolean` on a pass — there's an `isComplete(pass)` you apply. The data stays a faithful record; the reading of it is opt-in and inspectable. It also covers what a per-event-type accessor structurally can't: a corner isn't a StatsBomb event type, it's a *kind of pass*, so it can only be a predicate. ## Providers * **[StatsBomb](/docs/data/statsbomb/events)** — open data: competitions, matches, lineups, [events](/docs/data/statsbomb/events) and [360 tracking](/docs/data/statsbomb/360). * Source: [statsbomb/open-data](https://github.com/statsbomb/open-data) · [specifications](https://github.com/statsbomb/open-data/tree/master/doc) · [free data hub](https://statsbomb.com/what-we-do/hub/free-data/) * **[SkillCorner](/docs/data/skillcorner/tracking)** — broadcast tracking at 10 fps, plus the derived [dynamic events](/docs/data/skillcorner/dynamic-events) and [phases of play](/docs/data/skillcorner/phases-of-play). * Source: [SkillCorner/opendata](https://github.com/SkillCorner/opendata) · [documentation](https://skillcorner.github.io/opendata/) · [skillcorner.com](https://skillcorner.com/) * **[Wyscout](/docs/data/wyscout/events)** — the Pappalardo et al. dataset: 1,941 matches of event data across the 2017/18 big-five leagues, plus World Cup 2018 and Euro 2016. * Source: [figshare collection](https://figshare.com/collections/Soccer_match_event_dataset/4415000) · [data paper](https://www.nature.com/articles/s41597-019-0247-7) · [event mirror](https://github.com/koenvo/wyscout-soccer-match-event-dataset) SkillCorner also has its own pitch type. Its coordinates are metres from the centre spot, so `` plots them raw — no lifting, no conversion: ```tsx p.x} y={(p) => p.y} /> ``` Wyscout has its own pitch type too — a 0–100 percentage grid like Opta's, but with a different origin: top-left with y increasing downward, where Opta's is bottom-left with y increasing upward. `` plots its coordinates raw: ```tsx s.x} y={(s) => s.y} /> ``` ## Licensing This package ships **no data** — it fetches from whatever URL you give it. The default URLs point at each provider's own open-data repository, and **every provider asks to be credited** in anything you publish from their data: * **StatsBomb** — [open-data](https://github.com/statsbomb/open-data) is released under StatsBomb's own user agreement rather than an OSI licence. Read the [usage terms](https://statsbomb.com/what-we-do/hub/free-data/free-data-usage-terms/) before you rely on it. * **SkillCorner** — [opendata](https://github.com/SkillCorner/opendata) is MIT-licensed, and SkillCorner ask for credit and a mention when you publish work built on it. * **Wyscout** — the [Pappalardo et al. dataset](https://www.nature.com/articles/s41597-019-0247-7) is CC BY 4.0, the most permissive of the three; cite the paper in anything you publish. Its official release (figshare) has no per-match JSON file, so events are fetched from a community mirror that splits the same archive per match with no field renamed — the reference files (competitions, teams, players) come from figshare directly. --- # Coordinates & pitch types Source: https://www.pitchkitjs.com/docs/guides/coordinates > Provider coordinate systems, the transform pipeline, and how to move data between them. Every football data provider draws its pitch on a different grid. PitchKit makes the grid an explicit choice — the `type` prop on `` — and feeds every layer through one transform pipeline, so your data goes in **in its provider's native units** and everything stays aligned. ## Supported pitch types | `type` | Extent | Origin | y direction | Notes | | ------------- | --------- | --------------- | ----------- | ----------------------------- | | `statsbomb` | 120 × 80 | top-left | down | Abstract units | | `opta` | 100 × 100 | bottom-left | up | Normalized percentage grid | | `uefa` | 105 × 68 | bottom-left | up | Real metres | | `skillcorner` | 105 × 68 | **centre spot** | up | Real metres, per-match extent | | `wyscout` | 100 × 100 | top-left | down | Normalized percentage grid | ```tsx {/* data in 120x80, y-down */} {/* data in 0-100, y-up */} {/* metres from the centre spot */} {/* data in 0-100, y-down */} ``` `opta` and `wyscout` are both 0–100 on both axes, but not interchangeable: Opta's origin is bottom-left with y increasing upward, Wyscout's is top-left with y increasing downward. Plotting one provider's data on the other's type mirrors the pitch vertically, and nothing errors — every coordinate is still in range. A percentage grid renders at the **real pitch's proportions**, not as a square, because the transform converts each axis to metres before deriving the pitch's on-screen shape — x spans 105 m of grass and y only 68 m, even though both run 0–100. The markings still don't scale with an axis override; see below. `skillcorner` is the one grid whose origin is not a corner: its coordinates are metres measured from the centre spot, so x runs `-52.5..52.5` and y `-34..34`. Layers take that raw, with no lifting step — see [the data loaders](/docs/data/skillcorner/tracking). It is also the one grid whose real-world extent varies. SkillCorner pitches are the stadiums’ actual sizes (104, 105 and 106 m across their open data), so pass the match’s own values when you want the outline exact: ```tsx ``` The markings deliberately do not scale with it — a penalty area is 16.5 m deep on any pitch — so only the outline, halfway line and goal lines move. Overriding a normalized grid like `opta` throws instead, because a percentage grid has no metres to set. Marking positions (box sizes, penalty spots, circle radii) are sourced from mplsoccer's published per-provider constants rather than derived from real-world metres — real event data is published against those exact landmark values, and deriving our own would silently misalign rendered markings against it. ## Orientation and cropping Display orientation is a *view* concern, not a fact about the data — the same StatsBomb coordinates render horizontally or vertically: ```tsx import { cropForHalf, getPitchDimensions } from "@pitchkit/core"; const dims = getPitchDimensions("statsbomb"); // Attacking-half shot map framing: s.x} y={(s) => s.y} /> ; ``` `crop` takes any `{ x0, y0, x1, y1 }` window in provider units; `cropForHalf` is the convenience for the most common one. ## Converting between providers `createStandardizeTransform` maps coordinates from one provider grid to another (mplsoccer's `Standardizer`) — useful when combining data sources: ```tsx import { createStandardizeTransform, getPitchDimensions } from "@pitchkit/core"; const optaToStatsBomb = createStandardizeTransform( getPitchDimensions("opta"), getPitchDimensions("statsbomb"), ); const [x, y] = optaToStatsBomb([50, 50]); // -> [60, 40] ``` ## Pixels and back Inside a ``, [`usePitch()`](/docs/api/react/functions/usePitch) exposes the active pixel transform: `transform.toPixel` for provider → screen, and `transform.toProvider` for screen → provider — the inverse you need to ingest clicks or pointer positions as pitch coordinates. --- # Layers Source: https://www.pitchkitjs.com/docs/guides/layers > The mental model — a pitch container, overlay children, and typed accessors. Five things to hold in your head — that's the whole model: 1. **`` owns the coordinate system.** Declare the provider via `type`; every child reads the resulting transform from context. 2. **Children are layers, stacked in render order.** Later siblings paint on top of earlier ones, exactly like the DOM. 3. **Accessors map your data to visuals.** Every visual prop takes a static value or a typed per-datum function `(d, i) => value`. 4. **Colours are CSS variables** — see [Theming](/docs/styling/theming). 5. **Responsive is the default** — see [Responsive](/docs/guides/responsive). ```tsx p.x} y={(p) => p.y} binsX={12} binsY={8} /> p.x} y={(p) => p.y} x2={(p) => p.x2} y2={(p) => p.y2} /> s.x} y={(s) => s.y} r={(s) => 3 + s.xg * 9} /> ``` ## The overlay set | Overlay | Draws | Good for | | ---------------------------------------------------------- | ---------------------------------- | --------------------------------- | | [``](/docs/overlays/scatter) | One circle per datum | Shots, touches, positions | | [``](/docs/overlays/arrows) | Directional line + head per datum | Pass maps | | [``](/docs/overlays/comet) | Tapered, optionally fading trail | Carries, runs | | [``](/docs/overlays/annotate) | Text label per datum | Player names, callouts | | [``](/docs/overlays/heatmap) | Binned grid on canvas | Density, xG surfaces | | [``](/docs/overlays/positional-heatmap) | Juego de Posición zones on canvas | Positional play, zone comparisons | | [``](/docs/overlays/hexbin) | Hexagonal binned density on canvas | Dense touch/event maps | | [``](/docs/overlays/kde) | Smooth density surface on canvas | Pressure, territory | | [``](/docs/overlays/flow) | One aggregate arrow per zone | Pass direction by zone | | [``](/docs/overlays/polygon) | Arbitrary closed shape | Zone highlights | | [``](/docs/overlays/convex-hull) | Hull of a point set | Team/player shape | | [``](/docs/overlays/voronoi) | Nearest-player tessellation | Space control | | [``](/docs/overlays/goal-angle) | Wedge to both posts | Shot quality context | The accessor pattern is what keeps PitchKit shape-agnostic: there's no required data schema — `x={(shot) => shot.location[0]}` adapts any provider's records without a pre-processing step. ## SVG vs canvas Marks are SVG (crisp, inspectable, hoverable — most take a `tooltip` accessor). The density layers are the exception: ``, ``, `` and `` paint to a ``, where a handful of fills beat thousands of DOM nodes. The split is per-layer and automatic — you compose both kinds freely in one pitch. ## Escape hatch For custom marks the built-ins don't cover, call [`usePitch()`](/docs/api/react/functions/usePitch) from any child of `` to get the pitch dimensions, viewport, and pixel transform, then emit your own SVG. The landing page's interactive hero uses it to turn clicks back into provider coordinates. --- # Next.js & SSR Source: https://www.pitchkitjs.com/docs/guides/nextjs-ssr > Server-rendered pitches under the App Router, and the one boundary rule to know. `@pitchkit/react` server-renders to real SVG — verified against `renderToString` in the test suite and against a real Next.js App Router app (`examples/react-nextjs/` in the repo). The initial HTML response contains the full pitch and its marks; hydration attaches interactivity without re-drawing anything. ## The one rule: originate the tree in a client component Every layer takes accessor *functions* as props (`x={(p) => p.x}`), and React Server Components **cannot pass functions as props** across the server → client boundary. Compose your `` tree *inside* a `"use client"` component, and render that from your page: ```tsx // app/ShotMapPanel.tsx "use client"; import { Pitch, Scatter } from "@pitchkit/react"; import { shots } from "./data"; export function ShotMapPanel() { return ( s.x} y={(s) => s.y} /> ); } ``` ```tsx // app/page.tsx — stays a Server Component import { ShotMapPanel } from "./ShotMapPanel"; export default function Page() { return ; } ``` Putting the `` tree directly in a Server Component page fails `next build` with *"Functions cannot be passed directly to Client Components"* — that's this rule, not a PitchKit limitation. `"use client"` governs hydration and prop serialization, **not** whether SSR happens — the panel above is still fully server-rendered into the initial HTML. ## Heatmaps are client-only by design `` paints to a ``, which has no server-renderable content — it renders nothing on the server and paints after hydration. The pitch and any SVG layers around it still SSR normally. ## Data fetching Fetch on the server as usual and pass plain data down — arrays and objects serialize fine across the boundary; only functions don't. Accessors live in the client component, next to the JSX that uses them. --- # Responsive Source: https://www.pitchkitjs.com/docs/guides/responsive > Pitches fill their container by default; fixed pixels are the opt-out. Responsiveness isn't a prop — it's the default. A `` fills its container's width, keeps the pitch's own aspect ratio, and re-renders through a `ResizeObserver` as the container changes. Sizing a pitch means sizing its parent, like an ``: ```tsx
…
``` ## First paint and SSR Before the first client-side measurement (including on the server, where there's nothing to measure), the pitch renders at a nominal size **with the correct aspect ratio** — so server-rendered output is never distorted, and the post-hydration refinement only adjusts pixel scale, not shape. No flicker, no placeholder box. The aspect ratio tracks what's actually shown: a `crop` to the attacking half reserves space for half a pitch, not a whole one. ## Fixed size: the opt-out Pass both `width` and `height` to pin exact pixels — for image export, fixed-layout embeds, or canvas work: ```tsx … ``` ## The heatmap caveat `` paints to a ``, which needs real pixel dimensions up front — it can't lean on the SVG's responsive viewBox. Use a fixed-size pitch, or measure your own container and pass the result down (the same technique `` uses internally): ```tsx const containerRef = useRef(null); const [width, setWidth] = useState(480); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
e.x} y={(e) => e.y} binsX={12} binsY={8} />
); ``` Every heatmap example on this site (see the [gallery](/gallery)) uses this pattern — open one and copy it. --- # Annotate Source: https://www.pitchkitjs.com/docs/overlays/annotate > Place text labels at pitch coordinates. ```tsx import { Annotate, Pitch } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). const zones = [ { x: 20, y: 40, label: "Defensive third" }, { x: 60, y: 40, label: "Middle third" }, { x: 100, y: 40, label: "Attacking third" }, ]; /** `` places text labels at pitch coordinates. */ export function AnnotateBasic() { return ( z.x} y={(z) => z.y} label={(z) => z.label} offsetY={-4} /> ); } ``` ## Usage `` takes `x`/`y`/`label` accessors, plus optional `offsetX`/`offsetY` to nudge label position away from its anchor point. ```tsx z.x} y={(z) => z.y} label={(z) => z.label} offsetY={-4} /> ``` --- # Arrows Source: https://www.pitchkitjs.com/docs/overlays/arrows > Draw a directional pass or carry map between two points. ```tsx import { Arrows, Pitch } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). const passes = [ { from: { x: 12, y: 40 }, to: { x: 30, y: 12 } }, { from: { x: 30, y: 12 }, to: { x: 55, y: 40 } }, { from: { x: 55, y: 40 }, to: { x: 85, y: 15 } }, { from: { x: 85, y: 15 }, to: { x: 95, y: 40 } }, ]; /** `` draws a directional pass/carry map from `x,y` to `x2,y2`. */ export function ArrowsBasic() { return ( p.from.x} y={(p) => p.from.y} x2={(p) => p.to.x} y2={(p) => p.to.y} strokeWidth={2} strokeOpacity={0.85} /> ); } ``` ## Usage `` takes `x`/`y` (start) and `x2`/`y2` (end) accessors per datum — good for pass maps and set-piece routines. ```tsx p.from.x} y={(p) => p.from.y} x2={(p) => p.to.x} y2={(p) => p.to.y} /> ``` --- # Comet Source: https://www.pitchkitjs.com/docs/overlays/comet > A tapered, optionally fading trail — good for carries and off-ball runs. ```tsx import { Comet, Pitch } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A single dribble, tapering as it moves. const carries = [ { from: { x: 40, y: 60 }, to: { x: 58, y: 52 } }, { from: { x: 58, y: 52 }, to: { x: 76, y: 44 } }, { from: { x: 76, y: 44 }, to: { x: 94, y: 38 } }, ]; /** `` draws a tapered, optionally fading trail — good for carries and runs. */ export function CometBasic() { return ( c.from.x} y={(c) => c.from.y} x2={(c) => c.to.x} y2={(c) => c.to.y} gradient /> ); } ``` ## Usage Like ``, `` takes start (`x`/`y`) and end (`x2`/`y2`) accessors, plus `startWidth`/`endWidth` to control the taper and `gradient` to fade opacity along its length. ```tsx c.from.x} y={(c) => c.from.y} x2={(c) => c.to.x} y2={(c) => c.to.y} gradient /> ``` --- # Convex Hull Source: https://www.pitchkitjs.com/docs/overlays/convex-hull > The convex hull of a point set, rendered as a filled polygon — good for touch maps. ```tsx import { ConvexHull, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A player's touches across a half. const touches = [ { x: 40, y: 20 }, { x: 65, y: 15 }, { x: 90, y: 25 }, { x: 95, y: 55 }, { x: 70, y: 65 }, { x: 45, y: 50 }, { x: 68, y: 38 }, // interior ]; /** `` renders the convex hull of a point set — good for a player's touch map. */ export function ConvexHullBasic() { return ( t.x} y={(t) => t.y} /> t.x} y={(t) => t.y} r={3} /> ); } ``` ## Usage `` takes `x`/`y` accessors per datum (one point per datum, like ``) and renders a single filled polygon around the outermost points. ```tsx t.x} y={(t) => t.y} /> ``` --- # Flow Source: https://www.pitchkitjs.com/docs/overlays/flow > Pass/movement data binned by direction and volume, drawn as sized/colored arrows. ```tsx import { Flow, Pitch } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A cluster of passes moving upfield. const passes = [ { from: { x: 20, y: 40 }, to: { x: 45, y: 35 } }, { from: { x: 22, y: 42 }, to: { x: 48, y: 38 } }, { from: { x: 18, y: 38 }, to: { x: 42, y: 30 } }, { from: { x: 60, y: 30 }, to: { x: 85, y: 25 } }, { from: { x: 62, y: 32 }, to: { x: 88, y: 22 } }, { from: { x: 95, y: 60 }, to: { x: 105, y: 45 } }, ]; /** `` bins pass data by start location and draws one arrow per bin, sized/colored by volume. */ export function FlowBasic() { return ( p.from.x} y={(p) => p.from.y} x2={(p) => p.to.x} y2={(p) => p.to.y} binsX={8} binsY={6} /> ); } ``` ## Usage `` takes `x`/`y` (start) and `x2`/`y2` (end) accessors per datum, bins them by start location into a `binsX` x `binsY` grid, and draws one arrow per occupied bin — from the bin's center toward the mean end point, colored and sized by that bin's pass volume. ```tsx p.from.x} y={(p) => p.from.y} x2={(p) => p.to.x} y2={(p) => p.to.y} binsX={8} binsY={6} /> ``` --- # Goal Angle Source: https://www.pitchkitjs.com/docs/overlays/goal-angle > The angle subtended by the goal mouth at each point, rendered as a wedge. ```tsx import { GoalAngle, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A few shot locations. const shots = [ { x: 108, y: 40 }, { x: 95, y: 55 }, { x: 102, y: 22 }, ]; /** `` renders the angle subtended by the goal mouth at each point. */ export function GoalAngleBasic() { return ( s.x} y={(s) => s.y} /> s.x} y={(s) => s.y} r={3} /> ); } ``` ## Usage `` takes `x`/`y` accessors per datum and draws a wedge from that point to both goalposts. Pass `goal` (`"left" | "right" | "nearest"`, default `"nearest"`) to control which goal is measured against. ```tsx s.x} y={(s) => s.y} /> ``` --- # Heatmap Source: https://www.pitchkitjs.com/docs/overlays/heatmap > Canvas-based binned density, for shot maps and touch maps. ```tsx import { useEffect, useRef, useState } from "react"; import { Heatmap, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80) — a fuller shot map: a dense cluster of // good chances inside the box, a spread of half-chances around its edge, // and a few speculative long-range efforts. `xg` stands in for a real // expected-goals value, weighting the heatmap by shot quality rather than // raw count. const shots: { x: number; y: number; xg: number }[] = [ { x: 108, y: 38, xg: 0.62 }, { x: 104, y: 42, xg: 0.41 }, { x: 110, y: 35, xg: 0.55 }, { x: 106, y: 33, xg: 0.34 }, { x: 101, y: 45, xg: 0.28 }, { x: 113, y: 40, xg: 0.71 }, { x: 98, y: 30, xg: 0.19 }, { x: 96, y: 48, xg: 0.15 }, { x: 111, y: 44, xg: 0.48 }, { x: 103, y: 36, xg: 0.33 }, { x: 109, y: 41, xg: 0.52 }, { x: 90, y: 40, xg: 0.12 }, { x: 88, y: 25, xg: 0.06 }, { x: 85, y: 55, xg: 0.08 }, { x: 95, y: 60, xg: 0.09 }, { x: 99, y: 20, xg: 0.11 }, { x: 105, y: 50, xg: 0.22 }, { x: 115, y: 42, xg: 0.58 }, { x: 78, y: 40, xg: 0.04 }, { x: 70, y: 38, xg: 0.03 }, ]; const PITCH_ASPECT = 120 / 80; const FALLBACK_WIDTH = 480; /** * `` bins point density onto a canvas, which needs a fixed pixel * size up front — unlike the SVG mark layers on other pages, it can't * lean on 's own responsive ResizeObserver. This example measures * its own container instead (the same technique uses internally), * so the pitch still fills the preview card exactly like every other * example rather than sitting at a mismatched fixed size. */ export function HeatmapBasic() { const containerRef = useRef(null); const [width, setWidth] = useState(FALLBACK_WIDTH); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
s.x} y={(s) => s.y} weight={(s) => s.xg} binsX={10} binsY={7} colorMin="#0f3d24" colorMax="#fb923c" style={{ opacity: 0.85 }} /> s.x} y={(s) => s.y} r={2.5} fill="white" fillOpacity={0.7} />
); } ``` ## Usage `` bins `data` by `x`/`y` (optionally weighted by a `weight` accessor) and paints them to a `` layered inside the pitch's SVG via ``. Unlike the SVG mark layers above, it needs a fixed-size `` (`width`/`height`) — canvas has no server-rendered content to size against before the client measures its container. ```tsx s.x} y={(s) => s.y} binsX={8} binsY={6} /> ``` ## Keeping the pitch markings visible A filled density layer paints over the lines underneath it. Set `linesOnTop` on the pitch's `appearance` — mplsoccer's `line_zorder` — to paint the markings above the layers instead. Only the markings move; the grass surface and stripes stay underneath either way. ```tsx {/* ... */} ``` --- # Hexbin Source: https://www.pitchkitjs.com/docs/overlays/hexbin > Hexagonal density binning, for dense touch and event maps. ```tsx import { useEffect, useMemo, useRef, useState } from "react"; import { Hexbin, Pitch } from "@pitchkit/react"; /** * A deterministic stand-in for a full match's touch data — hexbin only * earns its keep at densities where listing every point inline would be * unreadable. Seeded so the docs render identically on every build. */ function generateTouches(count: number) { let seed = 20260909; const random = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; // Box-Muller, so touches cluster around a centre-of-gravity in the // attacking half rather than spreading uniformly across the pitch. const normal = () => Math.sqrt(-2 * Math.log(random() || 1e-9)) * Math.cos(2 * Math.PI * random()); // Rounded to 2dp deliberately: `Math.log`/`Math.cos` are allowed to // differ in the last bit between engines, so full-precision coordinates // would differ between the server render and the browser. const round = (value: number) => Math.round(value * 100) / 100; return Array.from({ length: count }, () => ({ x: round(Math.min(119, Math.max(1, 72 + normal() * 22))), y: round(Math.min(79, Math.max(1, 40 + normal() * 17))), })); } const PITCH_ASPECT = 120 / 80; const FALLBACK_WIDTH = 480; /** * `` paints to a canvas, which needs a fixed pixel size up front, * so this example measures its own container (the same technique * `` uses internally) rather than leaning on 's responsive * mode. */ export function HexbinBasic() { const containerRef = useRef(null); const [width, setWidth] = useState(FALLBACK_WIDTH); const touches = useMemo(() => generateTouches(600), []); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
t.x} y={(t) => t.y} binsX={18} colorMin="#0f3d24" colorMax="#facc15" stroke="rgba(0, 0, 0, 0.25)" strokeWidth={0.5} style={{ opacity: 0.9 }} />
); } ``` ## Usage `` bins `data` into a hexagonal lattice instead of a rectangular grid — mplsoccer's `hexbin`. Hexagons pack more evenly than squares (every neighbour is the same distance away), so dense event data reads with less of the axis-aligned banding a `` shows at the same resolution. ```tsx t.x} y={(t) => t.y} binsX={18} /> ``` ## Cell size `binsX` is the number of hexagon **columns** across the pitch length; the cell size follows from it, so the same value looks right on a 120x80 StatsBomb pitch and a 100x100 Opta one. It defaults to `20`. ## Empty cells Unlike ``, hexagons with no data aren't drawn at all, so the pitch stays visible wherever there was no activity — no `colorMin` wash to opt out of. The lattice is clipped to the pitch outline, so edge hexagons don't spill past the touchlines. Pass `weight` to sum a value per hexagon (e.g. xG) rather than counting points, and `stroke` to outline each cell. Like every Canvas layer, this needs a fixed-size `` (`width`/`height`). ## Keeping the pitch markings visible A filled density layer paints over the lines underneath it. Set `linesOnTop` on the pitch's `appearance` — mplsoccer's `line_zorder` — to paint the markings above the layers instead: ```tsx t.x} y={(t) => t.y} /> ``` Only the *markings* move — the grass surface and stripes stay underneath either way. It's off by default so that discrete SVG marks (a scatter dot on the penalty spot) still sit on top of the lines, which is what you want everywhere except a density fill. --- # KDE Source: https://www.pitchkitjs.com/docs/overlays/kde > A smooth kernel density surface, for pressure and territory maps. ```tsx import { useEffect, useRef, useState } from "react"; import { KDE, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). Defensive pressure events from one // half: a high press concentrated around the opposition's left channel, // with a second, looser cluster in front of the defensive line. const pressures: { x: number; y: number }[] = [ { x: 88, y: 22 }, { x: 92, y: 26 }, { x: 85, y: 19 }, { x: 90, y: 31 }, { x: 96, y: 24 }, { x: 83, y: 28 }, { x: 94, y: 18 }, { x: 87, y: 33 }, { x: 99, y: 29 }, { x: 91, y: 15 }, { x: 45, y: 44 }, { x: 51, y: 38 }, { x: 48, y: 52 }, { x: 55, y: 47 }, { x: 42, y: 49 }, { x: 58, y: 41 }, { x: 50, y: 58 }, { x: 62, y: 51 }, { x: 70, y: 62 }, { x: 75, y: 55 }, ]; const PITCH_ASPECT = 120 / 80; const FALLBACK_WIDTH = 480; /** * `` paints to a canvas, which needs a fixed pixel size up front, so * this example measures its own container (the same technique `` * uses internally) rather than leaning on 's responsive mode. */ export function KdeBasic() { const containerRef = useRef(null); const [width, setWidth] = useState(FALLBACK_WIDTH); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
p.x} y={(p) => p.y} bandwidth={7} colorMin="#facc15" colorMax="#b91c1c" maxOpacity={0.85} /> p.x} y={(p) => p.y} r={1.6} fill="white" fillOpacity={0.65} />
); } ``` ## Usage `` estimates a continuous density surface from point data via a 2D Gaussian kernel — mplsoccer's `kdeplot`. Where `` is a hard histogram, each point here spreads influence over its neighbourhood, so a handful of events reads as a cloud rather than a grid of cells. ```tsx p.x} y={(p) => p.y} /> ``` ## Bandwidth `bandwidth` is the smoothing radius in **provider units** (7 StatsBomb units ≈ 7 yards). Leave it unset and each axis gets its own bandwidth from Silverman's rule of thumb — `σ · n^(-1/6)` — computed from the data's own spread, which is the same default seaborn's `kdeplot` starts from. Set it explicitly when the data's spread isn't the right smoothing scale, e.g. a small sample you want to read tightly. ```tsx p.x} y={(p) => p.y} bandwidth={7} /> ``` ## Opacity and resolution Density is drawn with a per-cell opacity ramp: zero density is fully transparent and the peak sits at `maxOpacity` (default `0.9`), so the pitch stays visible under the tail of the surface rather than being washed in `colorMin`. `resolution` is the grid the estimate is sampled on, per axis (default `64`). Raise it for a smoother surface, lower it for speed. Like every Canvas layer, this needs a fixed-size `` (`width`/`height`). ## Keeping the pitch markings visible A filled density layer paints over the lines underneath it. Set `linesOnTop` on the pitch's `appearance` — mplsoccer's `line_zorder` — to paint the markings above the layers instead. Only the markings move; the grass surface and stripes stay underneath either way. ```tsx {/* ... */} ``` --- # Polygon Source: https://www.pitchkitjs.com/docs/overlays/polygon > An arbitrary closed shape from a list of points — good for highlighting zones. ```tsx import { cropForHalf, getPitchDimensions } from "@pitchkit/core"; import { Polygon, VerticalPitch } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A single highlighted zone in the // attacking half — the half this example crops to. const zones = [ { vertices: [ [80, 18], [120, 18], [120, 62], [80, 62], ] as const, }, ]; const dimensions = getPitchDimensions("statsbomb"); /** * `` draws an arbitrary closed shape — good for highlighting zones. * Shown here cropped to the attacking half (`cropForHalf`) and rotated * vertical (``), the usual framing for a single-zone or * shot-map style view. */ export function PolygonBasic() { return ( z.vertices} /> ); } ``` ## Usage `` takes a `points` accessor per datum, returning that shape's vertices as `[x, y]` pairs in provider coordinates. One datum draws one filled polygon. ```tsx z.vertices} /> ``` --- # Positional Heatmap Source: https://www.pitchkitjs.com/docs/overlays/positional-heatmap > Density binned into Juego de Posición zones instead of a uniform grid. ```tsx import { useEffect, useRef, useState } from "react"; import { Pitch, PositionalHeatmap } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). A midfielder's touches over a match: // heaviest through the left half-space and the middle third, thinning out // in both penalty areas. const touches: { x: number; y: number }[] = [ { x: 34, y: 22 }, { x: 41, y: 18 }, { x: 45, y: 26 }, { x: 52, y: 21 }, { x: 58, y: 30 }, { x: 63, y: 24 }, { x: 49, y: 33 }, { x: 55, y: 38 }, { x: 61, y: 41 }, { x: 67, y: 35 }, { x: 72, y: 28 }, { x: 78, y: 24 }, { x: 70, y: 45 }, { x: 66, y: 52 }, { x: 59, y: 48 }, { x: 51, y: 55 }, { x: 44, y: 44 }, { x: 38, y: 39 }, { x: 31, y: 47 }, { x: 26, y: 40 }, { x: 82, y: 33 }, { x: 88, y: 27 }, { x: 94, y: 31 }, { x: 86, y: 44 }, { x: 91, y: 52 }, { x: 105, y: 38 }, { x: 110, y: 42 }, { x: 20, y: 36 }, { x: 14, y: 41 }, { x: 47, y: 12 }, { x: 53, y: 9 }, { x: 60, y: 66 }, { x: 68, y: 71 }, { x: 75, y: 63 }, { x: 42, y: 60 }, { x: 36, y: 68 }, { x: 57, y: 43 }, { x: 62, y: 37 }, { x: 50, y: 40 }, { x: 65, y: 46 }, ]; const PITCH_ASPECT = 120 / 80; const FALLBACK_WIDTH = 480; /** * Like ``, `` paints to a canvas, which needs * a fixed pixel size up front — so this example measures its own container * (the same technique `` uses internally) rather than leaning on * 's responsive mode. */ export function PositionalHeatmapBasic() { const containerRef = useRef(null); const [width, setWidth] = useState(FALLBACK_WIDTH); useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setWidth(entry.contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
t.x} y={(t) => t.y} colorMin="#0f3d24" colorMax="#fb923c" stroke="rgba(255, 255, 255, 0.35)" strokeWidth={1} style={{ opacity: 0.85 }} />
); } ``` ## Usage `` aggregates the same way `` does — count per zone, or the sum of a `weight` accessor — but the cells come from the **pitch markings** rather than a `binsX` x `binsY` grid: the penalty-area lines, the halfway line and the midpoints between them give six columns, and the touchlines, penalty-area edges and six-yard-box edges give five lateral bands. This is mplsoccer's `bin_statistic_positional` + `heatmap_positional`, ported zone-for-zone, so the layout matches what analysts already read. ```tsx t.x} y={(t) => t.y} /> ``` ## Layouts The default `"full"` layout is not a grid — it's the 20-zone Juego de Posición board: the two flank bands split across all six columns, the three central bands split only at the penalty-area lines, and each penalty area as a single wide zone. | `layout` | Zones | Shape | | ------------------ | ----- | ------------------------------------- | | `"full"` (default) | 20 | The Juego de Posición board | | `"horizontal"` | 5 | Lateral bands only, full pitch length | | `"vertical"` | 6 | Columns only, full pitch width | ```tsx t.x} y={(t) => t.y} layout="horizontal" /> ``` ## Zone outlines Zone boundaries are off by default — they compete with the pitch markings they're derived from. Pass `stroke` (and optionally `strokeWidth`) to draw them, as the example above does. Like ``, this layer renders to a `` inside the pitch's SVG via ``, so it needs a fixed-size `` (`width`/`height`). ## Keeping the pitch markings visible A filled density layer paints over the lines underneath it. Set `linesOnTop` on the pitch's `appearance` — mplsoccer's `line_zorder` — to paint the markings above the layers instead. Only the markings move; the grass surface and stripes stay underneath either way. ```tsx {/* ... */} ``` --- # Scatter Source: https://www.pitchkitjs.com/docs/overlays/scatter > Plot discrete points on a pitch, with per-point styling and hover tooltips. ```tsx import { Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). const players = [ { name: "GK", x: 12, y: 40 }, { name: "LB", x: 30, y: 12 }, { name: "CB", x: 28, y: 40 }, { name: "RB", x: 30, y: 68 }, { name: "CM", x: 55, y: 40 }, { name: "LW", x: 85, y: 15 }, { name: "ST", x: 95, y: 40 }, { name: "RW", x: 85, y: 65 }, ]; /** `` plots discrete points with per-point styling and hover tooltips. */ export function ScatterBasic() { return ( p.x} y={(p) => p.y} r={7} stroke="white" strokeWidth={2} tooltip={(p) => p.name} /> ); } ``` ## Usage `` takes a `data` array plus `x`/`y` accessors; `r`, `fill`, `stroke`, `strokeWidth`, and `tooltip` are all optional and accept either a static value or a per-datum function. ```tsx p.x} y={(p) => p.y} r={7} tooltip={(p) => p.name} /> ``` --- # Voronoi Source: https://www.pitchkitjs.com/docs/overlays/voronoi > Voronoi tessellation over a point set, clipped to the pitch — good for coverage/space. ```tsx import { Pitch, Scatter, Voronoi } from "@pitchkit/react"; type Team = "home" | "away"; interface Player { name: string; team: Team; x: number; y: number; } // StatsBomb coordinates (120 x 80). Two opposing back lines, so cells read // as which team controls which space rather than one undifferentiated mesh. const players: Player[] = [ { name: "LB", team: "home", x: 40, y: 15 }, { name: "CB", team: "home", x: 35, y: 35 }, { name: "CB", team: "home", x: 35, y: 55 }, { name: "RB", team: "home", x: 40, y: 70 }, { name: "LB", team: "away", x: 80, y: 15 }, { name: "CB", team: "away", x: 85, y: 35 }, { name: "CB", team: "away", x: 85, y: 55 }, { name: "RB", team: "away", x: 80, y: 70 }, ]; const TEAM_COLOR: Record = { home: "#3b82f6", away: "#f97316" }; /** * `` tessellates space by nearest player. `fill` accepts a * per-datum accessor, so opposing teams can be colored differently — here * by `p.team` — the same way any other per-datum visual prop works. */ export function VoronoiBasic() { return ( p.x} y={(p) => p.y} fill={(p) => TEAM_COLOR[p.team]} tooltip={(p) => `${p.team} ${p.name}`} /> p.x} y={(p) => p.y} r={4} stroke="white" strokeWidth={1.5} /> ); } ``` ## Usage `` takes `x`/`y` accessors per datum (one site per datum) and renders one cell per datum, clipped to the pitch outline. `fill`/`stroke`/etc. accept per-datum accessors, so opposing teams can be colored differently by keying off a field on the datum. ```tsx p.x} y={(p) => p.y} fill={(p) => (p.team === "home" ? "#3b82f6" : "#f97316")} /> ``` --- # Pitch Palettes Source: https://www.pitchkitjs.com/docs/styling/palettes > Colour a pitch and its marks with Tailwind classes, using your own palette. Every shot from the Euro 2024 final, loaded from StatsBomb open data. Switch palettes to see the same chart in each one. ```tsx import { useEffect, useState } from "react"; import { Pitch, Scatter } from "@pitchkit/react"; import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb"; import type { StatsBombShot } from "@pitchkit/data-providers/statsbomb"; /** Euro 2024 final — Spain 2–1 England, Berlin, 14 July 2024. */ const EURO_2024_FINAL = 3943043; interface TeamClasses { /** The team's name and score in the header. */ text: string; /** Shots that weren't goals. */ shot: string; /** Goals. */ goal: string; } interface Palette { name: string; card: string; muted: string; pitch: string; spain: TeamClasses; england: TeamClasses; } // Every colour is a Tailwind class. The palette colours themselves // (`newsprint-paper`, `dracula-pink`, …) are added to the theme in globals.css. const PALETTES: Palette[] = [ { name: "Newsprint", card: "bg-newsprint-paper text-newsprint-ink", muted: "text-newsprint-muted", pitch: "pitch-surface-newsprint-paper pitch-stripe-newsprint-stripe pitch-lines-newsprint-rule pitch-line-width-1", spain: { text: "text-newsprint-red", shot: "fill-newsprint-red/35 stroke-newsprint-red", goal: "fill-newsprint-red stroke-newsprint-paper", }, england: { text: "text-newsprint-navy", shot: "fill-newsprint-navy/35 stroke-newsprint-navy", goal: "fill-newsprint-navy stroke-newsprint-paper", }, }, { name: "Analyst navy", card: "bg-analyst-card text-analyst-text", muted: "text-analyst-muted", pitch: "pitch-surface-analyst-pitch pitch-stripe-analyst-stripe pitch-lines-analyst-line pitch-line-width-1", spain: { text: "text-analyst-orange", shot: "fill-analyst-orange/30 stroke-analyst-orange", goal: "fill-analyst-orange stroke-analyst-pitch", }, england: { text: "text-analyst-sky", shot: "fill-analyst-sky/30 stroke-analyst-sky", goal: "fill-analyst-sky stroke-analyst-pitch", }, }, { name: "Dracula", card: "bg-dracula-darker text-dracula-foreground", muted: "text-dracula-comment", pitch: "pitch-surface-dracula-background pitch-stripe-dracula-stripe pitch-lines-dracula-comment pitch-line-width-[1.5]", spain: { text: "text-dracula-pink", shot: "fill-dracula-pink/30 stroke-dracula-pink", goal: "fill-dracula-pink stroke-dracula-background", }, england: { text: "text-dracula-cyan", shot: "fill-dracula-cyan/30 stroke-dracula-cyan", goal: "fill-dracula-cyan stroke-dracula-background", }, }, { name: "Gruvbox", card: "bg-gruvbox-bg0-hard text-gruvbox-fg", muted: "text-gruvbox-fg4", pitch: "pitch-surface-gruvbox-bg pitch-stripe-gruvbox-bg0-soft pitch-lines-gruvbox-fg4 pitch-line-width-[1.5]", spain: { text: "text-gruvbox-red", shot: "fill-gruvbox-red/30 stroke-gruvbox-red", goal: "fill-gruvbox-red stroke-gruvbox-bg", }, england: { text: "text-gruvbox-yellow", shot: "fill-gruvbox-yellow/30 stroke-gruvbox-yellow", goal: "fill-gruvbox-yellow stroke-gruvbox-bg", }, }, ]; /** Marker area grows with xG, so a 0.7 chance reads as roughly ten times a 0.07 one. */ const radius = (shot: StatsBombShot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 14; const tooltip = (shot: StatsBombShot) => `${shot.player?.name ?? "Unknown"}, ${shot.minute}' — ${shot.shot.statsbomb_xg.toFixed(2)} xG`; /** StatsBomb records every shot attacking left to right. Flip one team to face the other way. */ const mirror = (shot: StatsBombShot): StatsBombShot => ({ ...shot, x: 120 - shot.x, y: 80 - shot.y, }); function TeamShots({ data, classes }: { data: StatsBombShot[]; classes: TeamClasses }) { return ( <> !isGoal(s))} x={(s) => s.x} y={(s) => s.y} r={radius} strokeWidth={1.25} className={classes.shot} tooltip={tooltip} /> s.x} y={(s) => s.y} r={radius} strokeWidth={1.5} className={classes.goal} tooltip={tooltip} /> ); } function summary(data: StatsBombShot[]) { const xg = data.reduce((total, s) => total + s.shot.statsbomb_xg, 0); return { goals: data.filter(isGoal).length, detail: `${data.length} shots · ${xg.toFixed(2)} xG`, }; } /** * Every shot from the Euro 2024 final, in four palettes. Spain attack right, * England left; marker size is xG and goals are solid. Switching palette * swaps class strings and nothing else. */ export function StylingShotMapBasic() { const [all, setAll] = useState([]); const [failed, setFailed] = useState(false); const [palette, setPalette] = useState(PALETTES[0]!); useEffect(() => { fetchMatchEvents(EURO_2024_FINAL) // Period 5 is the penalty shootout; there wasn't one, but it isn't open play either. .then((events) => setAll(shots(events).filter((s) => s.period <= 4))) .catch(() => setFailed(true)); }, []); const spain = all.filter((s) => s.team.name === "Spain"); const england = all.filter((s) => s.team.name === "England").map(mirror); const home = summary(spain); const away = summary(england); return (
{PALETTES.map((p) => ( ))}
England {away.goals}
{away.detail}
{failed ? "Couldn't reach StatsBomb open data." : all.length === 0 ? "Loading…" : "Euro 2024 final"}
{home.goals} Spain
{home.detail}

Marker size is xG · solid markers are goals · data: StatsBomb

); } ``` The pitch and the marks are coloured entirely by Tailwind classes. Nothing on the chart passes a colour prop. ## Setup The examples use the `pitch-*` utilities from [Tailwind](/docs/styling/tailwind#the-pitch-background-a-utility-recipe). Add those to your stylesheet first. ## Adding a palette Add the palette's colours to your theme. This is the Dracula block the example uses: ```css /* globals.css */ @theme { --color-dracula-darker: #21222c; --color-dracula-background: #282a36; --color-dracula-stripe: #2f3242; --color-dracula-comment: #6272a4; --color-dracula-foreground: #f8f8f2; --color-dracula-pink: #ff79c6; --color-dracula-cyan: #8be9fd; } ``` Each colour is now available to every colour utility, including the pitch ones: ```tsx ``` The same names work for the card around the chart (`bg-dracula-darker`, `text-dracula-pink`), so the header and the pitch share one palette. ## The four palettes The colours for every palette in the example: ```css /* globals.css */ @theme { --color-newsprint-paper: #f4efe6; --color-newsprint-stripe: #ede6da; --color-newsprint-rule: #8a8074; --color-newsprint-ink: #1f1f1f; --color-newsprint-muted: #6b645b; --color-newsprint-red: #c1121f; --color-newsprint-navy: #1d3557; --color-analyst-card: #0d1f2b; --color-analyst-pitch: #122c3d; --color-analyst-stripe: #163447; --color-analyst-line: #cfcfcf; --color-analyst-text: #e2e8f0; --color-analyst-muted: #94a3b8; --color-analyst-orange: #f97316; --color-analyst-sky: #7dd3fc; --color-dracula-darker: #21222c; --color-dracula-background: #282a36; --color-dracula-stripe: #2f3242; --color-dracula-comment: #6272a4; --color-dracula-foreground: #f8f8f2; --color-dracula-pink: #ff79c6; --color-dracula-cyan: #8be9fd; --color-gruvbox-bg0-hard: #1d2021; --color-gruvbox-bg: #282828; --color-gruvbox-bg0-soft: #32302f; --color-gruvbox-fg4: #a89984; --color-gruvbox-fg: #ebdbb2; --color-gruvbox-red: #fb4934; --color-gruvbox-yellow: #fabd2f; } ``` ## Colouring marks Pass `fill-*` and `stroke-*` classes through a mark's `className`. Leave out its `fill` and `stroke` props: if either is set, it takes precedence over the class. ```tsx s.x} y={(s) => s.y} r={radius} className="fill-dracula-pink/30 stroke-dracula-pink" /> ``` A class applies to every marker in a layer, so each colour needs its own layer. The example draws four: goals and other shots for each team, filtered from the same array. Opacity modifiers such as `/30` work as they do anywhere else in Tailwind. To colour by a continuous value (xG on a colour scale, for instance), use the `fill` prop with a function instead. ## Related * [Theming](/docs/styling/theming): the `--pitch-*` variables, dark mode, and per-chart overrides without Tailwind. * [Tailwind](/docs/styling/tailwind): setting variables with arbitrary properties, and styling marks you didn't render yourself. * [StatsBomb events](/docs/data/statsbomb/events): loading the match data this example uses. --- # Tailwind Source: https://www.pitchkitjs.com/docs/styling/tailwind > Setting PitchKit's CSS variables and styling marks with Tailwind utilities. The pitch's colours come from the `--pitch-*` variables described in [Theming](/docs/styling/theming). This page covers reaching those variables, and individual marks, with Tailwind utilities. ## Marks you render yourself: `className` ``, ``, ``, ``, ``, ``, `` and `` fall back to a themed default `fill`/`stroke` — but that default is applied as inline `style`, and inline style always beats a class at the same CSS property. So a `className` utility only takes effect once the corresponding accessor prop is **omitted entirely** — passing both `fill="red"` and a `fill-*` class silently drops the class. ```tsx import { Comet, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). const run = { from: { x: 30, y: 20 }, to: { x: 70, y: 55 } }; const shots = [ { x: 95, y: 35 }, { x: 101, y: 42 }, { x: 108, y: 38 }, ]; /** * `className` reaches a mark you render yourself — but only once the * accessor prop it would otherwise fill (`fill`/`stroke`/`color`) is * omitted. That prop is applied as inline style, and inline style always * beats a class at the same property. */ export function TailwindClassnameBasic() { return ( d.from.x} y={(d) => d.from.y} x2={(d) => d.to.x} y2={(d) => d.to.y} className="fill-fuchsia-400" /> d.x} y={(d) => d.y} r={6} strokeWidth={1.5} className="fill-cyan-300 stroke-white transition-colors hover:fill-cyan-100" /> ); } ``` ## Marks you don't own: a `data-pitchkit-*` selector Sometimes you're styling a mark whose JSX isn't yours — inside a wrapped recipe component, for example. Every mark already carries `data-pitchkit-mark`/`data-pitchkit-layer` attributes with zero setup, reachable via Tailwind's arbitrary-variant selectors on a wrapping element: ```tsx import { Arrows, Pitch, Scatter } from "@pitchkit/react"; // StatsBomb coordinates (120 x 80). const pass = { from: { x: 40, y: 60 }, to: { x: 85, y: 30 } }; const shots = [ { x: 95, y: 35 }, { x: 101, y: 42 }, { x: 108, y: 38 }, ]; /** * For a mark whose JSX you don't own (e.g. inside a shadcn recipe * wrapping ``), reach it via the `data-pitchkit-mark` attribute * every mark already carries — no `className` prop needed on the mark * itself. The trailing `!` is required: these marks never got a * `className` to signal an opt-out, so their themed default is still an * inline style, which only an `!important` utility can out-rank. */ export function TailwindAttributeBasic() { return (
d.from.x} y={(d) => d.from.y} x2={(d) => d.to.x} y2={(d) => d.to.y} /> d.x} y={(d) => d.y} r={6} />
); } ``` Note the trailing `!` (Tailwind v4's important modifier) in the class list above — it's required here specifically. Unlike the `className` mechanism, these marks never got a `className` to signal an opt-out from their themed default, so an ordinary class can't out-rank the inline style they still carry. ## The pitch background: a `@utility` recipe The pitch background (outline, stripes, lines) isn't a mark at all — it's restyled only through the [`--pitch-*` variables](/docs/styling/theming), never via `className` on the shapes directly. Two ways to set them with Tailwind: **Zero setup**, using Tailwind's arbitrary-property syntax: ```tsx ``` **Or install this recipe once** (a "shadcn add"-style snippet — copy it into your own `globals.css`) to get autocompletable, first-class utilities instead, reading any color already in your Tailwind theme: ```css /* globals.css */ @utility pitch-surface-* { --pitch-surface: --value(--color-*); } @utility pitch-stripe-* { --pitch-stripe: --value(--color-*); } @utility pitch-lines-* { --pitch-lines: --value(--color-*); } ``` ```tsx import { Pitch } from "@pitchkit/react"; /** * The pitch background (outline/stripes/lines) isn't a mark — it's * restyled only via `--pitch-surface`/`--pitch-stripe`/`--pitch-lines`. * `pitch-surface-*`/`pitch-stripe-*`/`pitch-lines-*` are custom Tailwind * utilities (an installable `@utility ... --value(--color-*)` recipe, see * the Configuration > Tailwind page) that turn those variables into * first-class classes reading any color already in the project's theme. */ export function TailwindRecipeBasic() { return ( ); } ``` ## Line width `--pitch-line-width` gets the same recipe shape, but validates a bare/arbitrary number instead of looking one up in the theme — Tailwind has no rich preset scale for stroke width the way it does for color: ```css @utility pitch-line-width-* { --pitch-line-width: --value(number, [number]); } ``` ```tsx import { Pitch } from "@pitchkit/react"; /** * `--pitch-line-width` gets the same recipe shape as the color utilities, * but validates a bare/arbitrary number instead of looking one up in the * theme — Tailwind has no rich preset scale for stroke width to borrow * from the way it does for color. */ export function TailwindLineWidthBasic() { return ( ); } ``` `pitch-line-width-4` (bare) and `[--pitch-line-width:4]` (zero-setup arbitrary property) are equally reasonable here — pick whichever reads better in context. --- # Theming Source: https://www.pitchkitjs.com/docs/styling/theming > CSS variables only — set once, dark mode for free, override per chart. PitchKit's theming is **CSS variables only** — the same mechanism shadcn/ui uses internally. There are no JS theme objects and no per-instance colour props to thread through: set a variable once in your global stylesheet and every `` picks it up through the cascade. ## The variables | Variable | Controls | Default | | ------------------------ | --------------------------------------------------------- | --------------------------- | | `--pitch-surface` | Grass fill | `#1a472a` | | `--pitch-stripe` | Mow-stripe overlay | `rgba(255, 255, 255, 0.04)` | | `--pitch-lines` | Pitch markings; `` text; `` cell edges | `rgba(255, 255, 255, 0.8)` | | `--pitch-line-width` | Marking stroke width | `1.5` | | `--pitch-marker-primary` | Default mark colour (``, ``, ``…) | `#3b82f6` | | `--pitch-marker-goal` | `` wedge fill | `#f97316` | | `--pitch-tooltip-bg` | Tooltip background | `rgba(17, 17, 17, 0.92)` | | `--pitch-tooltip-color` | Tooltip text | `#fff` | ```css /* globals.css */ :root { --pitch-surface: #1a472a; --pitch-lines: rgba(255, 255, 255, 0.8); --pitch-marker-primary: #3b82f6; } ``` Every variable has a built-in fallback, so nothing is required — an unthemed `` still renders sensibly. ## Dark mode Because it's plain CSS, dark mode is a second override behind whatever convention your app already uses — a class, a media query, a `data-theme` attribute: ```css .dark { --pitch-surface: #0b1712; --pitch-lines: rgba(255, 255, 255, 0.35); } ``` No PitchKit configuration involved. This site's own examples work exactly this way. ## Per-chart overrides Variables cascade, so scoping an override to one chart is just a wrapper element: ```tsx
{/* this one renders dark */}
``` ## Structure vs colour The `appearance` prop is deliberately separate from theming: it toggles which *shapes* get painted (grass stripes, goal style), never colours. ```tsx ``` ## Individual marks Colour accessors (`fill`, `stroke`, `color`) always win over the themed default for the marks you pass them to — theming sets the baseline, accessors express data. For styling marks with utility classes instead, see [Tailwind](/docs/styling/tailwind), which covers `className`, the `data-pitchkit-*` attribute escape hatch, and the `@utility` recipe for the pitch background. --- # Dynamic Events Source: https://www.pitchkitjs.com/docs/data/skillcorner/dynamic-events > Possessions, passing options, off-ball runs and pressure — SkillCorner's derived event model, 322 columns wide. Dynamic events are SkillCorner's *derived* layer: what the tracking data implies about possessions, the options a player had, the runs made off the ball, and the pressure applied. The example plots off-ball runs as comets, from where each run began to where it ended. ```tsx import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { Comet, Pitch, Scatter } from "@pitchkit/react"; import { fetchDynamicEvents, fetchMatch, fetchMatches, hasPath, isSprint, offBallRuns, } from "@pitchkit/data-providers/skillcorner"; import type { SkillCornerMatch, SkillCornerMatchSummary, SkillCornerOffBallRun, } from "@pitchkit/data-providers/skillcorner"; import { DEFAULT_MATCH_ID, buttonClass, matchLabel, selectClass } from "./skillcorner-live"; const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const; interface Loaded { readonly match: SkillCornerMatch; readonly runs: readonly SkillCornerOffBallRun[]; } /** * Load a match's dynamic events and narrow them to off-ball runs. * * `fetchDynamicEvents` takes the *match*, not just its id, because the CSV's * coordinates are metres from the centre spot and placing them needs that * pitch's real dimensions. `offBallRuns` is the narrowing selector — the same * shape as StatsBomb's `shots()`/`passes()`. */ async function loadRuns(matchId: number, signal: AbortSignal): Promise { const match = await fetchMatch(matchId, { signal }); const events = await fetchDynamicEvents(match, { signal }); // `hasPath` keeps only runs with both a start and an end, which is what a // needs to draw. return { match, runs: offBallRuns(events).filter(hasPath) }; } function useRuns(matchId: number) { const [loaded, setLoaded] = useState<{ key: number; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); useEffect(() => { const controller = new AbortController(); loadRuns(matchId, controller.signal) .then((value) => setLoaded({ key: matchId, value })) .catch(() => { if (!controller.signal.aborted) setFailed(true); }); return () => controller.abort(); }, [matchId]); return { loaded: loaded?.key === matchId ? loaded.value : undefined, failed }; } /** Match picker, any extra controls, and the status line. */ function MatchPicker({ value, onChange, status, children, }: { value: number; onChange: (id: number) => void; status: ReactNode; children?: ReactNode; }) { const [matches, setMatches] = useState([]); useEffect(() => { fetchMatches() .then(setMatches) .catch(() => undefined); }, []); return ( <>
{children}

{status}

); } /** * Off-ball runs from a real SkillCorner match, drawn as comets that taper * from where the run began to where it ended. * * Event coordinates are normalised to the attacking direction, so every run * points the same way regardless of which half it happened in. */ export function SkillcornerEventsBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const [sprintsOnly, setSprintsOnly] = useState(false); const { loaded, failed } = useRuns(matchId); const runs = (loaded?.runs ?? []).filter((run) => !sprintsOnly || isSprint(run)); const color = (run: SkillCornerOffBallRun) => run.team_id === loaded?.match.home_team.id ? TEAM_COLORS[0] : TEAM_COLORS[1]; return (
run.x_start ?? 0} y={(run) => run.y_start ?? 0} x2={(run) => run.x_end ?? 0} y2={(run) => run.y_end ?? 0} color={color} startWidth={0.3} endWidth={1.4} gradient tooltip={(run) => `${run.player_name ?? "Unknown"} — ${run.event_subtype?.replace(/_/g, " ") ?? "run"}, ${ run.distance_covered?.toFixed(0) ?? "?" } m` } /> run.x_end ?? 0} y={(run) => run.y_end ?? 0} r={1.2} fill={color} fillOpacity={0.9} />
); } ``` ## Loading them ```ts import { fetchMatch, fetchDynamicEvents } from "@pitchkit/data-providers/skillcorner"; const match = await fetchMatch(1874553); const events = await fetchDynamicEvents(match); ``` `fetchDynamicEvents` takes the **match**, not just its id, because the coordinates are metres from the centre spot and placing them needs that pitch's real dimensions. The file is around 4 MB — fetch once and cache. Already have the CSV on disk? `parseDynamicEvents(text, match)` is pure, no network. And `loadDynamicEvents(url, match)` takes any URL if you keep a mirror. ## Four event types ```ts import { playerPossessions, passingOptions, offBallRuns, onBallEngagements, ofEventType, } from "@pitchkit/data-providers/skillcorner"; playerPossessions(events); // a player's time on the ball passingOptions(events); // every team-mate who was available to receive offBallRuns(events); // movement away from the ball onBallEngagements(events); // pressure, presses, duels ``` In one sampled match those split roughly 2,500 / 960 / 880 / 540 — so **`passing_option` is over half of everything**, because SkillCorner emits one per available receiver per possession. Plot them unfiltered and they bury the rest. Unlike StatsBomb's, this discriminant is top level, so `event.event_type === "off_ball_run"` narrows natively. The guards exist anyway, and also check the row carries what the type implies. ## Predicates ```ts import { breaksDefensiveLine, isRunBehind, isSprint, wasReceived, isCompletePass, leadToShot, hasPath, } from "@pitchkit/data-providers/skillcorner"; offBallRuns(events).filter(isSprint).filter(breaksDefensiveLine); passingOptions(events).filter(wasReceived); ``` `isSprint` reads SkillCorner's own `speed_avg_band`, not a threshold invented here. `hasPath` keeps only events with both a start and an end — which is what `` and `` need. **A completed pass is explicit here.** `pass_outcome === "successful"`, unlike StatsBomb where success is the *absence* of `pass.outcome`. `isCompletePass` is a real equality check rather than a workaround. ## The 322 columns The source CSV is **322 columns wide** — expected possession value, line breaks, pressure bands, passing-option scoring, distances to the last defensive line, and much more. Typing all of them would be a worse lie than leaving them honest, so the roughly forty you plot or filter on are typed, and every other column stays reachable under its original name: ```ts const run = offBallRuns(events)[0]; run.distance_covered; // typed run.xthreat; // typed run["affected_line_breaking_passing_option_xthreat"]; // still there, as the CSV wrote it ``` ## Coordinates point at the goal being attacked **Dynamic-event `x` is normalised to the attacking direction**: positive x always points at the goal that team is attacking, in both halves. So a shot-ending phase is at high x whichever way the team kicked. This is the **opposite** of the [tracking](/docs/data/skillcorner/tracking) file, whose coordinates are absolute and swap ends at half time. Plot one with the other's assumption and you mirror half a match with no error to tell you. The two files share a frame counter but not a coordinate convention. Verified against a full match: every `wide_left` and `half_space_left` row has `y > 0` and every `*_right` row `y < 0`, with no crossover — so `y > 0` is the attacking team's left, which is "up" on a y-up pitch. The parser also adds corner-origin `pitchX`/`pitchY` if your chart wants them, but `` takes `x_start`/`y_start` directly. ## Reading the fields ```tsx run.x_start} y={(run) => run.y_start} x2={(run) => run.x_end} y2={(run) => run.y_end} tooltip={(run) => `${run.player_name} — ${run.event_subtype}, ${run.distance_covered} m`} /> ``` Run subtypes are SkillCorner's own vocabulary — `run_ahead_of_the_ball`, `coming_short`, `dropping_off`, `support`, `cross_receiver`, `overlap` — kept as they're spelled. ## Official documentation The 322 columns are SkillCorner's, and so is the vocabulary. Their documentation is the authority on what each one means: * **[SkillCorner Open Data docs](https://skillcorner.github.io/opendata/)** — the dynamic-event model, including the expected-possession-value and pressure sections, plus a link to their full CSV specification PDF. * **[SkillCorner/opendata](https://github.com/SkillCorner/opendata)** — the repository, with worked [tutorials](https://github.com/SkillCorner/opendata/tree/master/notebooks/tutorials) covering game intelligence, dynamic events and phases of play. MIT-licensed, and SkillCorner ask to be credited in anything you publish. ## Next [Phases of play](/docs/data/skillcorner/phases-of-play) — the same match described as a sequence of possessions rather than individual actions. --- # Phases of Play Source: https://www.pitchkitjs.com/docs/data/skillcorner/phases-of-play > A match as a sequence of possessions — where each one started, where it got to, and whether it produced a shot. Phases of play describe a match at the level above individual actions: each stretch with the ball in play and one team in possession, classified by what both teams were doing. It's the smallest of SkillCorner's three files at around **110 KB**, and often the fastest way into a match. ```tsx import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { Arrows, Pitch } from "@pitchkit/react"; import { fetchMatch, fetchMatches, fetchPhasesOfPlay, phaseLedToShot, } from "@pitchkit/data-providers/skillcorner"; import type { SkillCornerMatch, SkillCornerMatchSummary, SkillCornerPhase, } from "@pitchkit/data-providers/skillcorner"; import { DEFAULT_MATCH_ID, matchLabel, selectClass } from "./skillcorner-live"; const TEAM_COLORS = ["var(--pitch-marker-primary)", "var(--pitch-marker-goal)"] as const; interface Loaded { readonly match: SkillCornerMatch; readonly phases: readonly SkillCornerPhase[]; } /** * Load a match's phases of play — the smallest of SkillCorner's three files * at around 110 KB, and the one that describes the match as a sequence of * possessions rather than as individual actions. */ async function loadPhases(matchId: number, signal: AbortSignal): Promise { const match = await fetchMatch(matchId, { signal }); const phases = await fetchPhasesOfPlay(match, { signal }); // Only phases that travelled somewhere can be drawn as an arrow. return { match, phases: phases.filter( (phase) => typeof phase.x_start === "number" && typeof phase.x_end === "number", ), }; } function usePhases(matchId: number) { const [loaded, setLoaded] = useState<{ key: number; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); useEffect(() => { const controller = new AbortController(); loadPhases(matchId, controller.signal) .then((value) => setLoaded({ key: matchId, value })) .catch(() => { if (!controller.signal.aborted) setFailed(true); }); return () => controller.abort(); }, [matchId]); return { loaded: loaded?.key === matchId ? loaded.value : undefined, failed }; } /** Match picker, any extra controls, and the status line. */ function MatchPicker({ value, onChange, status, children, }: { value: number; onChange: (id: number) => void; status: ReactNode; children?: ReactNode; }) { const [matches, setMatches] = useState([]); useEffect(() => { fetchMatches() .then(setMatches) .catch(() => undefined); }, []); return ( <>
{children}

{status}

); } /** * Phases of play from a real SkillCorner match: one arrow per possession, * from where it started to where it got to. The ones that produced a shot * are what you're looking for, so they're the ones picked out. */ export function SkillcornerPhasesBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const [phaseType, setPhaseType] = useState("all"); const { loaded, failed } = usePhases(matchId); const phaseTypes = useMemo(() => { const seen = new Set(); for (const phase of loaded?.phases ?? []) { if (phase.team_in_possession_phase_type) seen.add(phase.team_in_possession_phase_type); } return [...seen].sort(); }, [loaded]); const phases = (loaded?.phases ?? []).filter( (phase) => phaseType === "all" || phase.team_in_possession_phase_type === phaseType, ); return (
{phaseTypes.length > 0 && ( )} phase.x_start ?? 0} y={(phase) => phase.y_start ?? 0} x2={(phase) => phase.x_end ?? 0} y2={(phase) => phase.y_end ?? 0} stroke={(phase: SkillCornerPhase) => phaseLedToShot(phase) ? TEAM_COLORS[1] : TEAM_COLORS[0] } strokeOpacity={(phase: SkillCornerPhase) => (phaseLedToShot(phase) ? 0.9 : 0.22)} strokeWidth={(phase: SkillCornerPhase) => (phaseLedToShot(phase) ? 0.7 : 0.3)} headSize={4} tooltip={(phase) => `${phase.team_in_possession_shortname ?? "?"} — ${ phase.team_in_possession_phase_type?.replace(/_/g, " ") ?? "phase" }${phaseLedToShot(phase) ? " → shot" : ""}` } />
); } ``` Each arrow is one possession, start to end. The highlighted ones produced a shot. ## Loading them ```ts import { fetchMatch, fetchPhasesOfPlay } from "@pitchkit/data-providers/skillcorner"; const match = await fetchMatch(1874553); const phases = await fetchPhasesOfPlay(match); ``` Like the other loaders it takes the match, since coordinates are metres from the centre spot. One match has roughly 400 phases. ## What a phase carries ```ts interface SkillCornerPhase { index: number; frame_start: number; // a tracking frame number frame_end: number; period: number | null; minute_start: number | null; team_in_possession_id: number | null; team_in_possession_shortname: string | null; team_in_possession_phase_type: string | null; // build_up, counter, … team_out_of_possession_phase_type: string | null; // high_press, mid_block, low_block, … team_possession_lead_to_shot: boolean | null; team_possession_lead_to_goal: boolean | null; x_start: number | null; // normalised to the attacking direction y_start: number | null; x_end: number | null; y_end: number | null; team_in_possession_width_start: number | null; // how spread the team was, in metres team_in_possession_length_start: number | null; // …and the rest of the 44 columns, under their own names } ``` Two things here are hard to get anywhere else in open data. **Both teams are classified at once** — a phase says what the team in possession was doing *and* what the team out of possession was doing, so "build-up against a high press" is a filter rather than a judgement call. And **team width and length** are given directly in metres, which otherwise means deriving shape from tracking yourself. ## Filtering ```ts import { isPhaseType, phaseLedToShot, phaseLedToGoal } from "@pitchkit/data-providers/skillcorner"; const counters = phases.filter(isPhaseType("counter")); const dangerous = phases.filter(phaseLedToShot); const goals = phases.filter(phaseLedToGoal); ``` `isPhaseType` is a factory — it returns the predicate — so it composes with `.filter()` the same way the fixed ones do. ## Lining phases up with the other files `frame_start` and `frame_end` are **tracking frame numbers**, the same counter the [tracking file](/docs/data/skillcorner/tracking) uses and the same one [dynamic events](/docs/data/skillcorner/dynamic-events) carry. So going from "this possession led to a shot" to "show me it" needs no timestamp matching: ```ts const phase = phases.filter(phaseLedToShot)[0]; // The tracking frames for exactly that possession const frames = await fetchTrackingWindow(match, { fromFrame: phase.frame_start, toFrame: phase.frame_end, }); // The dynamic events inside it const inPhase = events.filter( (event) => event.frame_start >= phase.frame_start && event.frame_start <= phase.frame_end, ); ``` That shared counter is the most useful property of this dataset, and the reason the three files are worth treating as one thing rather than three. Phase coordinates follow the **dynamic-events** convention, not tracking's: `x` is normalised so positive always points at the goal being attacked. Don't overlay them on absolute tracking positions without converting. ## Official documentation Phase types are SkillCorner's taxonomy, and their documentation defines them: * **[SkillCorner Open Data docs](https://skillcorner.github.io/opendata/)** — the phases-of-play file, what each attacking and defending phase type means, and a link to the full CSV specification PDF. * **[SkillCorner/opendata](https://github.com/SkillCorner/opendata)** — the repository and its [tutorials](https://github.com/SkillCorner/opendata/tree/master/notebooks/tutorials). MIT-licensed, and SkillCorner ask to be credited in anything you publish. --- # 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; } /** * 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 { 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([]); useEffect(() => { fetchMatches() .then(setMatches) .catch(() => undefined); }, []); return ( <>

{status}

); } /** * A streamed clip of SkillCorner broadcast tracking, playing at 10 fps. * * Coordinates go in **raw**: `` 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 (
{frame && ( <> player.x} y={(player) => player.y} fill={fill} fillOpacity={0.13} stroke="rgba(255,255,255,0.18)" strokeWidth={0.4} /> 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 && ( ball.x ?? 0} y={(ball) => ball.y ?? 0} r={1.4} fill="#fff" stroke="#111" strokeWidth={0.4} /> )} )}
); } ``` ## 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: **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. **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. ## Plotting it Coordinates go in **raw**. SkillCorner measures in metres from the centre spot, and `` uses that same grid, so the accessors are just `(p) => p.x`: ```tsx p.x} y={(p) => p.y} /> p.x} y={(p) => p.y} r={2.4} /> ``` `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 ``` 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. ## 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. --- # 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 ( <>

{status}

); } /** * 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 (
visibleAreaPolygon(frame)} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth={1} /> player.x} y={(player) => player.y} fill={fill} fillOpacity={0.14} stroke="rgba(255,255,255,0.2)" strokeWidth={0.5} /> 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 } />
{clockLabel(moment.event)} · {moment.event.type.name} · {moment.event.team.name}
); } /** * 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 (
{ 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 && ( <>
{pair.map((moment) => ( ))}
)}
); } ``` ## 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. **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. 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 p.x} y={(p) => p.y} /> p.x} y={(p) => p.y} r={3.5} /> ``` `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 [``](/docs/overlays/polygon): ```tsx import { visibleAreaPolygon } from "@pitchkit/data-providers/statsbomb"; 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. --- # Events Source: https://www.pitchkitjs.com/docs/data/statsbomb/events > Competitions, matches and events from StatsBomb open data — typed, filtered, plotted. Everything below is live: the example fetches a real Euro 2024 match from StatsBomb's open-data repository in your browser, and the match picker switches between all 51 of them. ```tsx import { useEffect, useState } from "react"; import { cropForHalf, getPitchDimensions } from "@pitchkit/core"; import { Scatter, VerticalPitch } 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"; const dimensions = getPitchDimensions("statsbomb"); /** 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 ( <>

{status}

); } /** * A shot map built from a real Euro 2024 match, fetched in the browser. * * The data path is two lines: fetch the match, narrow to shots. After that * you're reading StatsBomb's own fields — `shot.statsbomb_xg`, * `shot.outcome.name` — with `x`/`y` already lifted into place for the * `` accessors. */ export function StatsbombEventsBasic() { 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; shots: StatsBombShot[] } | undefined>(); const [failed, setFailed] = useState(false); const loaded = result?.key === matchId ? result.shots : undefined; useEffect(() => { fetchMatchEvents(matchId) .then((events) => setResult({ key: matchId, shots: shots(events) })) .catch(() => setFailed(true)); }, [matchId]); return (
{ setFailed(false); setMatchId(next); }} status={ 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` } /> shot.x} y={(shot) => shot.y} r={(shot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 11} fill={(shot) => isGoal(shot) ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)" } fillOpacity={(shot) => (isGoal(shot) ? 0.95 : 0.55)} stroke="white" strokeWidth={(shot) => (isGoal(shot) ? 2 : 1)} tooltip={(shot) => `${shot.player?.name ?? "Unknown"} (${shot.team.name}) — ${shot.shot.outcome.name}, ${shot.shot.statsbomb_xg.toFixed(2)} xG` } />
); } ``` ## Getting a match `competitions.json` lists **competition-and-season pairs**, not competitions — the same competition appears once per season available. That's why `fetchMatches` needs both ids: ```ts import { fetchCompetitions, fetchMatches, fetchMatchEvents, } from "@pitchkit/data-providers/statsbomb"; const competitions = await fetchCompetitions(); // 80 competition-seasons const matches = await fetchMatches(55, 282); // Euro 2024: competition 55, season 282 const events = await fetchMatchEvents(matches[0].match_id); ``` Every fetch takes optional `{ baseUrl, fetch, signal }`, so you can point at a mirror or wrap the request — Next.js caching, a proxy agent, a stub in tests: ```ts const events = await fetchMatchEvents(3943043, { fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 86400 } }), }); ``` If you already have the JSON, skip the network entirely with `parseEvents(json)`, or point `loadEvents(url)` at wherever you keep it. Events files are large — a match is roughly 3 MB. Fetch once and cache; don't call `fetchMatchEvents` per render. ## Narrowing the feed A match's events are a mixed list of \~3,500 items. The selectors narrow it, and **they're the only thing that narrows the type**: ```ts import { shots, passes, carries, ofType } from "@pitchkit/data-providers/statsbomb"; shots(events); // StatsBombShot[] passes(events); // StatsBombPass[] carries(events); // StatsBombCarry[] ofType(events, "Duel"); // everything else, untyped but intact ``` Shots, passes and carries are typed explicitly. Every other event type — Duel, Dribble, Pressure, Goal Keeper, and the rest — comes back from `ofType` as a generic event with its own sub-object still attached. Nothing is dropped. ### Why `event.type.name === "Shot"` doesn't narrow StatsBomb's discriminant is nested one level down, inside `type`. TypeScript only narrows unions on *top-level* literal discriminants, so this compiles and runs correctly but fails to typecheck: ```ts if (event.type.name === "Shot") { event.shot.statsbomb_xg; // ✗ Property 'shot' does not exist on type 'StatsBombEvent' } ``` Use the guards, which narrow properly — and which also check the sub-object is really present rather than trusting the name: ```ts import { isShot } from "@pitchkit/data-providers/statsbomb"; if (isShot(event)) { event.shot.statsbomb_xg; // ✓ } ``` Hoisting a top-level discriminant would fix this, but only by inventing a field StatsBomb doesn't have — which is exactly what this package avoids. ## Predicates Composable filters over StatsBomb's own fields. They apply to the narrowed types, so they chain off a selector: ```ts import { passes, shots, isCorner, isCross, isGoal, isOnTarget, } from "@pitchkit/data-providers/statsbomb"; const corners = passes(events).filter(isCorner); const crosses = passes(events).filter(isCross); const goals = shots(events).filter(isGoal); const onTarget = shots(events).filter(isOnTarget); ``` Passes: `isComplete`, `isCorner`, `isFreeKick`, `isThrowIn`, `isCross`, `isThroughBall`, `isSwitch`, `isAssist`, `isKeyPass`. Shots: `isGoal`, `isPenalty`, `isOnTarget`. `isSetPiece` takes either. **A completed pass has no `outcome` at all.** StatsBomb encodes success as the *absence* of `pass.outcome`, not as a value — so `outcome.name === "Complete"` matches nothing, ever. That's what `isComplete` is for. (In one sampled match, 984 of 1,163 passes had no `outcome` key.) ## Reading the fields Once narrowed, you're reading StatsBomb's own structure — no translation layer: ```tsx shot.x} // lifted from location[0] y={(shot) => shot.y} // lifted from location[1] r={(shot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 11} fill={(shot) => (isGoal(shot) ? "orange" : "steelblue")} tooltip={(shot) => `${shot.player?.name} — ${shot.shot.outcome.name}`} /> ``` `x`/`y` (and `endX`/`endY` on shots, passes and carries) are the only additions the parser makes. `endZ` exists on shots only when the ball left the ground — StatsBomb writes a two-element `end_location` otherwise, which is why it's optional. A handful of event types genuinely carry no location at all — Starting XI, Half Start, Substitution, Tactical Shift — so `x`/`y` are optional on the base event type and **required** on shots, passes and carries. ## Official documentation This page covers loading the data; StatsBomb's own specification is the authority on what each field means: * **[Open Data Events v4.0.0 (PDF)](https://github.com/statsbomb/open-data/blob/master/doc/Open%20Data%20Events%20v4.0.0.pdf)** — every event type and qualifier, defined by StatsBomb. * **[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, plus the competitions, matches and lineups schemas. * **[statsbomb/open-data](https://github.com/statsbomb/open-data)** — the repository these loaders fetch from. * **[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/) — read the terms before you publish anything from it. ## Next [360 tracking](/docs/data/statsbomb/360) — every player's position at the moment of an event, joined onto these same events. --- # Events Source: https://www.pitchkitjs.com/docs/data/wyscout/events > Match events from the Wyscout open dataset — typed, filtered, plotted. Everything below is live: the example fetches a real match from the Wyscout open-data mirror in your browser, and the match picker switches between five recognisable fixtures. ```tsx import { useEffect, useState } from "react"; import { cropForHalf, getPitchDimensions } from "@pitchkit/core"; import { Scatter, VerticalPitch } from "@pitchkit/react"; import { fetchMatch, indexPlayersById, isGoal, shotGoalZone, shots, } from "@pitchkit/data-providers/wyscout"; import type { WyscoutEvent, WyscoutPlayer } from "@pitchkit/data-providers/wyscout"; import { DEFAULT_MATCH_ID, WYSCOUT_MATCHES, selectClass } from "./wyscout-live"; const dimensions = getPitchDimensions("wyscout"); /** The curated shortlist over a line of status text. */ function MatchSelector({ value, onChange, status, }: { value: number; onChange: (matchId: number) => void; status: string; }) { return ( <>

{status}

); } interface Loaded { readonly shots: readonly WyscoutEvent[]; readonly players: Map; } /** * A shot map built from a real Wyscout match, fetched in the browser. * * `shots(events)` first, `.filter(isGoal)` after — in that order. Wyscout * tags a goal on the conceding keeper's save as well as on the shot that * scored it, so filtering the *whole* feed for the goal tag counts each one * twice. Narrowing to shots first is what keeps the count honest. */ export function WyscoutEventsBasic() { 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; value: Loaded } | undefined>(); const [failed, setFailed] = useState(false); const loaded = result?.key === matchId ? result.value : undefined; useEffect(() => { fetchMatch(matchId) .then((match) => { setResult({ key: matchId, value: { shots: shots(match.events), players: indexPlayersById(match) }, }); }) .catch(() => setFailed(true)); }, [matchId]); return (
{ setFailed(false); setMatchId(next); }} status={ failed ? "Couldn't reach Wyscout open data." : loaded === undefined ? "Fetching the match (~480 KB)…" : `${loaded.shots.length} shots · ${loaded.shots.filter(isGoal).length} goals` } /> shot.x} y={(shot) => shot.y} r={(shot) => (isGoal(shot) ? 6 : 4)} fill={(shot) => isGoal(shot) ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)" } fillOpacity={(shot) => (isGoal(shot) ? 0.95 : 0.55)} stroke="white" strokeWidth={(shot) => (isGoal(shot) ? 2 : 1)} tooltip={(shot) => { const player = loaded?.players.get(shot.playerId)?.shortName ?? "Unknown"; // No end coordinate on a shot — where it went is a tag, read // through shotGoalZone, not positions[1]. See "Reading the // fields" below. const outcome = isGoal(shot) ? "Goal" : (shotGoalZone(shot) ?? "Blocked"); return `${player} — ${outcome}`; }} />
); } ``` ## Getting a match ```ts import { fetchMatch } from "@pitchkit/data-providers/wyscout"; const match = await fetchMatch(2499943); // Liverpool 4–3 Manchester City, 2018 const events = match.events; // both squads travel with it, ready to label a tooltip ``` `fetchMatch` returns the events **and** both teams' squads in one call — there's no second request to turn a `playerId` into a name. There's no `fetchMatches` here. The dataset's 1,941 matches have no published JSON index — only a generated Markdown table — so this page's picker is a curated shortlist rather than a live search. Any of the dataset's match ids works the same way with `fetchMatch`; a match file is roughly 480 KB. If you already have the JSON, skip the network entirely with `parseMatch(json)`, or point `loadMatch(url)` at wherever you keep it. ## Narrowing the feed Wyscout's discriminant is **top level** — unlike StatsBomb's, which is nested inside `type` — so a plain comparison narrows without a guard: ```ts import { duels, passes, shots } from "@pitchkit/data-providers/wyscout"; shots(events); // every Shot passes(events); // every Pass duels(events); // every Duel ``` `ofType(events, "Free Kick")` reaches anything without its own named selector. ## Predicates Wyscout puts almost everything in numeric **tags**, not fields — whether a pass found its target, whether a shot was a goal, which foot took it. `hasTag` is the primitive; the named predicates are built on it: ```ts import { hasTag, isAccurate, isGoal, isKeyPass, wonDuel, WYSCOUT_TAGS, } from "@pitchkit/data-providers/wyscout"; const goals = shots(events).filter(isGoal); const completed = passes(events).filter(isAccurate); const chances = passes(events).filter(isKeyPass); // isAccurate is really just: hasTag(somePass, WYSCOUT_TAGS.ACCURATE); ``` **Accuracy is explicit on both sides.** Every pass carries either `ACCURATE` (1801) or `NOT_ACCURATE` (1802) — unlike StatsBomb, where a completed pass is the *absence* of an outcome. `isAccurate(pass)` is a real equality check, not a workaround. ## A goal is tagged twice The `GOAL` tag sits on the shot that scored **and** on the conceding keeper's `Save attempt` — Wyscout tags the outcome from both sides of the same event. Measured across six full matches: tag 101 appeared on 15 shots, 19 save attempts, and 3 free kicks. Filter the whole feed for `isGoal` and every goal is counted twice. Narrow to `shots(events)` **first**, then filter — that's what keeps `shots(events).filter(isGoal).length` honest, and it's the order the example above uses. ## A shot has no end coordinate Every event carries a `positions` array, and most have two entries — a start and an end. A `Shot`'s second entry is a placeholder, not a location: across 9,765 events checked, it was always exactly `(100, 100)` or `(0, 0)`, never a real point. `Interruption` and `Offside` behave the same way. So `endX`/`endY` are simply **absent** on those three event types rather than present and wrong. Where a shot went is recorded instead as one of 23 goal-mouth tags — `shotGoalZone` reads them: ```ts import { shotGoalZone } from "@pitchkit/data-providers/wyscout"; shotGoalZone(goal); // "goal low left", "out high right", … ``` This can't be caught by checking the coordinate's *value* — `(100, 100)` is also a genuine corner-flag position for a corner kick. The exclusion has to be keyed on the event type, not on what the number happens to be. ## Coordinates point at the goal being attacked Like SkillCorner's dynamic events, and unlike its tracking file: `x` is **normalised to the attacking direction**, not absolute. `x: 100` is always the goal that event's team is attacking, in both halves, so both teams appear to attack left-to-right and nothing flips at half time. `` plots `x`/`y` raw — no lifting, no conversion. ## Reading the fields ```tsx shot.x} y={(shot) => shot.y} fill={(shot) => (isGoal(shot) ? "orange" : "steelblue")} tooltip={(shot) => (isGoal(shot) ? "Goal" : (shotGoalZone(shot) ?? "Blocked"))} /> ``` `x`/`y` are lifted from `positions[0]` for every event that has one. `endX`/`endY` are lifted the same way from `positions[1]` — except on `Shot`, `Interruption` and `Offside`, per above. ## Official documentation This page covers loading the data; Wyscout's own tag and event vocabularies are the authority on what each id means: * **[A public data set of spatio-temporal match events in soccer competitions](https://www.nature.com/articles/s41597-019-0247-7)** — Pappalardo et al., *Scientific Data* (2019), the paper the dataset accompanies. * **[figshare collection](https://figshare.com/collections/Soccer_match_event_dataset/4415000)** — the official release: competitions, teams, players, and the tag/event-id vocabularies this package's predicates are built from. * **[koenvo/wyscout-soccer-match-event-dataset](https://github.com/koenvo/wyscout-soccer-match-event-dataset)** — the mirror this package's `fetchMatch` reads from, which splits the official archive into one file per match with no field renamed. CC BY 4.0 — cite Pappalardo et al. (2019) in anything you publish from it.