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.
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 MBEvents 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 BayernAn 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.coordinatesare absolute, as seen on TV. Teams swap ends at half time, and which end each starts at varies by match.start.adjCoordinatesturn each event so the acting team attacks towards+x, in both halves. That is the team insquadId: 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 halfNarrowing 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'sSet 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.