# Impect events

Source: https://www.pitchkitjs.com/docs/data/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](https://www.impect.com), under their [terms of use](https://github.com/ImpectAPI/open-data/blob/main/LICENSE.pdf): non-commercial analytical and research use only, no redistribution, and credit to IMPECT in anything you publish.

```tsx
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 { 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" 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

```ts
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.

<Callout type="warn">
  A match's events are 5-6 MB of JSON. Fetch once and keep them; don't call `fetchMatchEvents` per
  render.
</Callout>

## 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_GOAL`s:

```ts
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:

```ts
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:

```ts
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
```

<Callout type="warn">
  **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.
</Callout>

## Predicates

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

```ts
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](https://github.com/ImpectAPI/open-data/blob/main/LICENSE.pdf) 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](https://github.com/ImpectAPI/open-data)** — the repository these
  loaders fetch from.
* **[Documentation (PDF)](https://github.com/ImpectAPI/open-data/blob/main/Documentation.pdf)**
  — every field, action type and coordinate frame, defined by Impect.
* **[kloppy](https://kloppy.pysport.org/user-guide/loading-data/impect/)** — the Python
  equivalent, if you want the same data in a notebook.
