PitchKit
DataImpect

Impect events

Every Bundesliga 2023/24 match from Impect's open data — typed, filtered, plotted.

Everything below is live: the example fetches a real Bundesliga 2023/24 match from Impect's open data in your browser, and the picker covers all 306 of them.

Data provided by IMPECT, under their terms of use: non-commercial analytical and research use only, no redistribution, and credit to IMPECT in anything you publish.

Fetching the match from Impect's open data (~5 MB)…

"use client";import { useEffect, useState } from "react";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { Scatter, VerticalPitch } from "@pitchkit/react";import {  fetchMatchEvents,  hasLocation,  isGoal,  isOwnGoal,  matchSeconds,  score,  shots,} from "@pitchkit/data-providers/impect";import type { ImpectEvent } from "@pitchkit/data-providers/impect";import { docsAppearance } from "./docs-appearance";import { DEFAULT_MATCH_ID, matchLabel, selectClass, useImpectSeason } from "./impect-live";const dimensions = getPitchDimensions("impect");/** Match-clock seconds as "48:47". */function clock(seconds: number): string {  const whole = Math.floor(seconds);  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;}/** * What the chart needs from a match: its located shots, and the goal and * own-goal events `score` counts. A few dozen events, where the whole match * is about 3,000, so keeping one per match visited stays cheap. */interface MatchSummary {  readonly shots: (ImpectEvent & { readonly x: number; readonly y: number })[];  readonly goals: ImpectEvent[];}function summarise(events: readonly ImpectEvent[]): MatchSummary {  return {    shots: shots(events).filter(hasLocation),    goals: events.filter((event) => event.actionType === "GOAL" || isOwnGoal(event)),  };}/** * A shot map from a real Bundesliga 2023/24 match, fetched in the browser * from Impect's open data. * * `x`/`y` are Impect's adjusted coordinates, where the shooting team attacks * towards +x, so both teams' shots land in the one half drawn here. */export function ImpectEventsBasic() {  const { season, failed: seasonFailed } = useImpectSeason();  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);  // One summary per match visited, so going back to a match doesn't  // download its ~5 MB of events again.  const [summaries, setSummaries] = useState<ReadonlyMap<number, MatchSummary>>(new Map());  const [failedMatch, setFailedMatch] = useState<number>();  useEffect(() => {    if (summaries.has(matchId)) return;    // Cancelled when the match changes, so a slow earlier download can't    // finish last and leave the chart showing nothing.    const controller = new AbortController();    fetchMatchEvents(matchId, { signal: controller.signal })      .then((events) => {        setSummaries((previous) => new Map(previous).set(matchId, summarise(events)));        setFailedMatch(undefined);      })      .catch(() => {        if (!controller.signal.aborted) setFailedMatch(matchId);      });    return () => controller.abort();  }, [matchId, summaries]);  const summary = summaries.get(matchId);  const match = season?.matches.find((m) => m.id === matchId);  const squadName = (id: number | null) =>    (id !== null && season?.squads.get(id)?.name) || "Unknown";  // Until the match list is in, there's no telling home from away.  const fill = (shot: ImpectEvent) =>    match && shot.squadId !== match.homeSquadId      ? "var(--pitch-marker-goal)"      : "var(--pitch-marker-primary)";  let status = "Fetching the match from Impect's open data (~5 MB)…";  if (failedMatch === matchId) status = "Couldn't reach Impect's open data.";  else if (summary) {    status = `${summary.shots.length} shots`;    if (match) {      // The match list carries no score, and own goals aren't shots.      const { home, away } = score(summary.goals, match);      const ownGoals = summary.goals.filter(isOwnGoal).length;      status += ` · ${home}–${away}${ownGoals ? ` (${ownGoals} own goal${ownGoals > 1 ? "s" : ""})` : ""}`;    } else if (seasonFailed) {      status += " · couldn't load the match list, so no score";    }  }  return (    <div>      <select        aria-label="Bundesliga 2023/24 match"        value={matchId}        disabled={!season}        onChange={(event) => setMatchId(Number(event.target.value))}        className={selectClass}      >        {!season && (          <option value={DEFAULT_MATCH_ID}>            {seasonFailed ? "Couldn't load the match list" : "Loading matches…"}          </option>        )}        {season?.matches.map((m) => (          <option key={m.id} value={m.id}>            {matchLabel(m, season)}          </option>        ))}      </select>      <p className="my-3 text-xs text-fd-muted-foreground">{status}</p>      <VerticalPitch type="impect" appearance={docsAppearance} crop={cropForHalf(dimensions)}>        <Scatter          data={summary?.shots ?? []}          x={(shot) => shot.x}          y={(shot) => shot.y}          r={(shot) => (isGoal(shot) ? 7 : 4.5)}          fill={fill}          fillOpacity={(shot) => (isGoal(shot) ? 0.95 : 0.55)}          stroke="white"          strokeWidth={(shot) => (isGoal(shot) ? 2 : 1)}          tooltip={(shot) =>            `${clock(matchSeconds(shot))} ${squadName(shot.squadId)} — ${shot.action ?? "shot"}${isGoal(shot) ? ", goal" : ""}`          }        />      </VerticalPitch>    </div>  );}

Getting a match

import {
  fetchMatches,
  fetchMatchEvents,
  fetchSquads,
  IMPECT_BUNDESLIGA_2023_24,
} from "@pitchkit/data-providers/impect";

const matches = await fetchMatches(IMPECT_BUNDESLIGA_2023_24); // 306 matches, ~180 KB
const squads = await fetchSquads(IMPECT_BUNDESLIGA_2023_24); // the 18 clubs
const match = matches.find((m) => m.id === 122838)!; // Werder Bremen v Bayern
const events = await fetchMatchEvents(match.id); // ~3,000 events, 5-6 MB

Events name teams by squadId and players by player.id; indexSquadsById and indexPlayersById turn those into names. fetchLineups(matchId) adds shirt numbers, starting positions and substitutions.

A match's events are 5-6 MB of JSON. Fetch once and keep them; don't call fetchMatchEvents per render.

The match list has no score

Count it from the events with score, which adds each side's GOAL events to the other side's OWN_GOALs:

import { score } from "@pitchkit/data-providers/impect";

score(events, match); // { home: 0, away: 4 }: Werder Bremen 0-4 Bayern

An own goal is credited to the team that conceded it, and has no position. A goal is two events: the SHOT with result: "SUCCESS", then a GOAL marker, also without a position. shots(events).filter(isGoal) gives the scoring shots, own goals excepted.

Two coordinate frames

Impect publish every position twice, on a 105×68 m pitch measured from the centre spot:

  • start.coordinates are absolute, as seen on TV. Teams swap ends at half time, and which end each starts at varies by match.
  • start.adjCoordinates turn each event so the acting team attacks towards +x, in both halves. That is the team in squadId: a defender's clearance is seen from the defender's side, not the attacker's.

The lifted x/y/endX/endY are the adjusted ones, which is why every shot in the example lands in the half drawn. <Pitch type="impect"> plots either frame raw, with y increasing to the attacking team's left.

The second half starts at 10,000 seconds

gameTime.gameTimeInSec restarts each period at a fixed offset: 0, 10,000, then 20,000 and 30,000 for extra time. Dividing it by 60 gives a minute of 166 just after half time. matchSeconds converts to the match clock, and sorting on gameTimeInSec is already play order:

import { inMatchOrder, inPeriod, matchSeconds } from "@pitchkit/data-providers/impect";

const [secondHalfKickOff] = inPeriod(events, 2);
secondHalfKickOff.gameTime.gameTimeInSec; // 10000
matchSeconds(secondHalfKickOff); // 2700, which is 45:00
inMatchOrder(events); // first-half stoppage time before the second half

Narrowing the feed

actionType is top level, so a plain comparison narrows without a guard. The selectors are convenience:

import { byTeam, passes, shots } from "@pitchkit/data-providers/impect";

shots(events); // penalties and direct free kicks included
passes(events); // PASS events only
byTeam(events, 33); // FC Bayern München's

Set pieces aren't PASS events. A corner, free kick, throw-in, goal kick or kick-off is its own action type, though each carries a pass object. A pass map from passes(events) misses them; events.filter(hasPass) is every ball played on purpose.

Predicates

Filters over Impect's own fields, for .filter():

import {
  isGoal,
  isHeader,
  isOwnGoal,
  isPenalty,
  isSuccessful,
} from "@pitchkit/data-providers/impect";

const goals = shots(events).filter(isGoal);
const completed = passes(events).filter(isSuccessful);

A pass's result is SUCCESS when it reached a teammate under pressure below 80, NEUTRAL when the receiver was under high pressure or a foul followed, and FAIL for a pass to an opponent, out of play or offside. About one shot in three has a shot.targetPoint (hasTargetPoint): where it would have crossed the goal line, in metres.

Licensing

Impect's open data is not openly licensed. Their terms of use allow non-commercial analytical and research use only, forbid editing, reproducing or redistributing the data, and require IMPECT to be credited, with their logo, in anything you publish from it. @pitchkit/data-providers ships none of it: every loader fetches from Impect's own repository when you call it.

Official documentation

  • ImpectAPI/open-data — the repository these loaders fetch from.
  • Documentation (PDF) — every field, action type and coordinate frame, defined by Impect.
  • kloppy — the Python equivalent, if you want the same data in a notebook.

On this page