# 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 (
    <>
      <select
        aria-label="Wyscout match"
        value={value}
        onChange={(event) => onChange(Number(event.target.value))}
        className={selectClass}
      >
        {WYSCOUT_MATCHES.map((match) => (
          <option key={match.id} value={match.id}>
            {match.label}
          </option>
        ))}
      </select>
      <p className="my-3 text-xs text-fd-muted-foreground">{status}</p>
    </>
  );
}

interface Loaded {
  readonly shots: readonly WyscoutEvent[];
  readonly players: Map<number, WyscoutPlayer>;
}

/**
 * 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 (
    <div>
      <MatchSelector
        value={matchId}
        onChange={(next) => {
          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`
        }
      />

      <VerticalPitch type="wyscout" crop={cropForHalf(dimensions)}>
        <Scatter
          data={loaded?.shots ?? []}
          x={(shot) => 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}`;
          }}
        />
      </VerticalPitch>
    </div>
  );
}
```

## 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.

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

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);
```

<Callout>
  **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.
</Callout>

## 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.

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

## 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", …
```

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

## 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. `<Pitch type="wyscout">` plots `x`/`y` raw — no lifting, no conversion.

## Reading the fields

```tsx
<Scatter
  data={shots(events)}
  x={(shot) => 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.
