PitchKit
Guides

Coordinates & pitch types

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 <Pitch> — 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

typeExtentOriginy directionNotes
statsbomb120 × 80top-leftdownAbstract units
opta100 × 100bottom-leftupNormalised percentage grid
uefa105 × 68bottom-leftupReal metres
skillcorner105 × 68centre spotupReal metres, per-match extent
wyscout100 × 100top-leftdownNormalised percentage grid
metrica1 × 1top-leftdownNormalised 0..1 grid
impect105 × 68centre spotupReal metres, standardised
<Pitch type="statsbomb">{/* data in 120x80, y-down */}</Pitch>
<Pitch type="opta">{/* data in 0-100, y-up */}</Pitch>
<Pitch type="skillcorner">{/* metres from the centre spot */}</Pitch>
<Pitch type="wyscout">{/* data in 0-100, y-down */}</Pitch>
<Pitch type="metrica">{/* data in 0-1, y-down */}</Pitch>
<Pitch type="impect">{/* metres from the centre spot, 105x68 */}</Pitch>

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. metrica has Wyscout's orientation on a 0..1 grid instead, so Metrica data on wyscout crowds into the top-left corner. mplsoccer calls this type metricasports.

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.

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:

<Pitch type="skillcorner" dimensions={{ length: match.pitch_length, width: match.pitch_width }} />

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 normalised 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:

import { cropForHalf, getPitchDimensions } from "@pitchkit/core";

const dims = getPitchDimensions("statsbomb");

// Attacking-half shot map framing:
<VerticalPitch type="statsbomb" crop={cropForHalf(dims)}>
  <Scatter data={shots} x={(s) => s.x} y={(s) => s.y} />
</VerticalPitch>;

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:

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 <Pitch>, 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.

On this page