Skip to main content

Sample data

The examples on this site draw real data, never invented rows. The datasets below are shared between them, all exported by the examples workspace as @sportsdataverse/examples/data. For every one, a test (examples/test/sample-data.test.ts) re-derives every value from captures committed in fixtures/examples (fixtures/shots for the shot-chart data), so a hand-typed number fails the examples gate. A second test fails when an example types its own table of rows instead of importing one of these.

STANDINGS: eight AFC teams, 2024​

Eight rows of the 2024 NFL regular season, the AFC West and AFC East. Each column's source:

  • wins, losses, ties, pf, pa: the team's 17 games in nflverse games.csv, which holds all 272 of the 2024 regular season.
  • result_last: the team's week 18 result (its last regular-season game), from the same file.
  • qb: the quarterback who started the most of the team's games in that file; qb_espn_id: his ESPN id, from nflverse players.csv.
  • srs_rank: the team's rank among all 32 by the Simple Rating System, computed from every regular-season game: the ratings that best fit each home margin as the difference of the two teams' ratings (least squares), summing to zero. The test solves it again.
  • net_epa: offensive EPA per play minus the EPA per play its defence allowed, over rush and pass plays with an EPA in nflverse's 2024 play-by-play. New England's value is left null on purpose, so every example also shows how a missing value renders.

tools/sample-data/nfl_2024.py writes the three trimmed nflverse files the test reads.

STANDINGS: eight AFC teams, 2024 regular season
teamconfdivisionwinslossestiespfpanet_epasrs_rankqbqb_espn_idresult_last
KCAFCWest15203853260.06310Patrick Mahomes3139477L
LACAFCWest11604023010.1019Justin Herbert4038941W
DENAFCWest10704253110.1088Bo Nix4426338W
LVAFCWest4130309434-0.14626Gardner Minshew4038524L
BUFAFCEast13405253680.194Josh Allen3918298L
MIAAFCEast890345364-0.01921Tua Tagovailoa4241479L
NYJAFCEast5120338404-0.04524Aaron Rodgers8439W
NEAFCEast413028941729Drake Maye4431452W
import { STANDINGS } from "@sportsdataverse/examples/data"
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";

// One text column per field, labelled with the field name, so the page shows the rows exactly as stored.
const fields = Object.keys(STANDINGS[0] ?? {}) as (keyof Standing)[];
const spec = defineTable<Standing>()
.columns((c) => fields.map((k) => c.text(k, { label: k })))
.title("STANDINGS: eight AFC teams, 2024 regular season")
.sourceNote('import { STANDINGS } from "@sportsdataverse/examples/data"')
.build();
await renderHTMLAsync(spec, STANDINGS);

NBA_SHOTS: one quarter of shots​

Every field-goal attempt of the fourth quarter of the Lakers at the Nuggets on 24 October 2023 (game 0022300061, opening night; Denver won 119-107): 38 shots, 22 by Denver and 16 by Los Angeles. They come from stats.nba.com's shotchartdetail for that game, captured with sportsdataverse-py, with LOC_X/LOC_Y renamed to the nba-legacy frame's x_legacy/y_legacy (tenths of a foot from the hoop). Made shots are green here, misses red.

import * as Plot from "@observablehq/plot";
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";

// stats.nba.com shots: x_legacy/y_legacy (the frame's default columns), tenths of a foot from the hoop,
// x across the court. The frame turns them into court feet; every shot lands on the -x half.
const shots = toSurfaceFrame(NBA_SHOTS, { from: "nba-legacy" });

const court = surface("basketball", "nba", { displayRange: "defense", arcResolution: 48 });
Plot.plot({
...surfaceScales(court),
width: 640,
marks: [
...surfaceMark(court),
Plot.dot(shots, { x: "surface_x", y: "surface_y", r: 6, fill: (d) => (d.made ? "#1b7837" : "#b2182b") }),
],
});

SUPER_BOWL_LIX_WP: one game's win probability​

Super Bowl LIX (9 February 2025, Philadelphia the home team; Philadelphia won 40-22 over Kansas City): ESPN's win probability for Philadelphia after each of 186 plays, against minutes played. It comes from ESPN's game summary (event 401671889), its winprobability entries joined to the plays they follow. The logos behind the lines are the teams' marks from the sdvplot archive.

Super Bowl LIX win probability, Philadelphia and Kansas City, by minute, with faint Eagles and Chiefs logos behind the lines
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { matchupColors } from "@sportsdataverse/sdvplot";
import { type WatermarkOptions, logoWatermarks } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";

// Two colours that read apart and on a light page: Philadelphia (home) first, as on Game on Paper.
const { light } = await matchupColors("PHI", "KC", { league: "nfl" });
const [phi, kc] = light;
const line = (label: string, color: string, wp: (p: number) => number) => ({
label,
data: SUPER_BOWL_LIX_WP.map((p) => ({ x: p.minute, y: wp(p.home_wp) })),
borderColor: color,
backgroundColor: color,
borderWidth: 2,
pointRadius: 0,
});

// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given). A
// browser loads each logo itself; Node passes `loadImage` (below).
const config = (o: Pick<WatermarkOptions, "loadImage"> = {}): ChartConfiguration<"line"> => ({
type: "line",
data: {
datasets: [line("Philadelphia", phi, (wp) => wp), line("Kansas City", kc, (wp) => 1 - wp)],
},
options: {
responsive: false,
animation: false,
scales: {
x: {
type: "linear",
min: 0,
max: 60,
ticks: { stepSize: 15 },
title: { display: true, text: "Minutes played" },
},
y: { min: 0, max: 1, ticks: { callback: (v) => `${Math.round(Number(v) * 100)}%` } },
},
},
// Game on Paper's watermarks: the home team top-left, the away team bottom-left, 40% opaque behind the lines
plugins: [logoWatermarks(["PHI", "KC"], { league: "nfl", size: 60, ...o })],
});
// In the browser: new Chart(canvas, config()) on a 640 x 320 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 320,
label:
"Super Bowl LIX win probability, Philadelphia and Kansas City, by minute, with faint Eagles and Chiefs logos behind the lines",
} as const;

Chart.register(...registerables);
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
Chart.defaults.font.family = "Source Sans 3";
// In Node the image plugins load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas (whose
// own loadImage(url) also fetches by itself). Keep the promises: once they settle, the plugins have redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
const canvas = createCanvas(browser.width, browser.height);
// Chart.js types want a DOM canvas; @napi-rs/canvas has the same 2D context. Outside a DOM, Chart.js draws
// synchronously on its BasicPlatform (no resize, no events).
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new Chart(ctx, config({ loadImage: load }));
await Promise.all(loads);

const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="320" alt="${browser.label}">`;

NBA_STANDINGS: the Pacific Division, 2023-24​

The five Pacific Division teams' final 2023-24 regular-season records (the season of NBA_SHOTS): stats.nba.com's leaguestandingsv3, as captured by sportsdataverse-py, with TeamName as team (the nickname, which sdvplot resolves), WINS, LOSSES and DivisionRank.

01020304050↑ wins
Pacific Division wins, 2023-24 regular season. Data: stats.nba.com
import * as Plot from "@observablehq/plot";
import { NBA_STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nba");
// The Pacific Division, 2023-24 (stats.nba.com): `team` is the nickname ("Lakers"), which sdvplot resolves.
Plot.plot({
height: 300,
caption: "Pacific Division wins, 2023-24 regular season. Data: stats.nba.com",
marks: [
Plot.barY(NBA_STANDINGS, { x: "team", y: "wins", fill: "team", sort: { x: "-y" } }),
axisLogos("x", { league: "nba", height: 0.12 }),
],
color: teamColor("nba", { values: NBA_STANDINGS.map((s) => s.team) }),
});

NHL_STANDINGS: the Atlantic Division, 2025-26​

The eight Atlantic Division teams' final 2025-26 regular-season records (82 games each), from the NHL's api-web standings/now, as captured by sportsdataverse-py on 24 May 2026: wins, losses, overtime losses and points.

020406080100↑ points
Atlantic Division points, 2025-26 regular season. Data: NHL api-web
import * as Plot from "@observablehq/plot";
import { NHL_STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nhl");
Plot.plot({
height: 300,
caption: "Atlantic Division points, 2025-26 regular season. Data: NHL api-web",
marks: [
Plot.barY(NHL_STANDINGS, { x: "team", y: "points", fill: "team", sort: { x: "-y" } }),
axisLogos("x", { league: "nhl", height: 0.12 }),
],
color: teamColor("nhl", { values: NHL_STANDINGS.map((s) => s.team) }),
});

NHL_SHOTS: one period of shots​

Every goal, shot on goal and missed shot of the first period of game 7 of the 2024 Stanley Cup Final (Edmonton at Florida, 24 June 2024; Florida won 2-1): 27 shots, 11 by Edmonton and 16 by Florida. They come from the NHL's api-web play-by-play for game 2023030417, as captured by sportsdataverse-py, with xCoord/yCoord (feet from centre ice) as x/y. Florida shot at the -x end that period.

import * as Plot from "@observablehq/plot";
import { NHL_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, surface, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nhl");

// NHL api-web reports xCoord/yCoord in feet from centre ice, the frame sporty's NHL rink is drawn in, so the shots
// plot as captured with no toSurfaceFrame. In this period Florida shot at the -x end and Edmonton at the +x end.
const rink = surface("nhl", { team: "FLA" });
const at = { x: "x", y: "y", r: 5 } as const;
Plot.plot({
...rink.scales,
width: 940,
color: teamColor("nhl", { values: NHL_SHOTS.map((s) => s.team), legend: true }),
marks: [
...rink.marks,
// goals and shots on goal filled in the shooting team's colour, misses white with a ring in it
Plot.dot(
NHL_SHOTS.filter((s) => s.type !== "missed-shot"),
{ ...at, fill: "team", stroke: "white" },
),
Plot.dot(
NHL_SHOTS.filter((s) => s.type === "missed-shot"),
{ ...at, fill: "white", stroke: "team", strokeWidth: 2 },
),
logos(
NHL_SHOTS.filter((s) => s.type === "goal"),
{ league: "nhl", x: "x", y: "y", team: "team", height: 0.08 },
),
],
});

SUPER_BOWL_LIX_TDS: one game's touchdowns​

Super Bowl LIX's six offensive touchdowns, three for each team, from the same ESPN game summary as SUPER_BOWL_LIX_WP: each play's offence and the line of scrimmage it started from (start.yardLine, 0-100 from Philadelphia's goal line). ESPN gives no position across the field.

[
  {
    "team": "PHI",
    "period": 1,
    "clock": "6:15",
    "yardline": 99,
    "field_x": 49,
    "field_y": null
  },
  {
    "team": "PHI",
    "period": 2,
    "clock": "1:35",
    "yardline": 88,
    "field_x": 38,
    "field_y": null
  },
  {
    "team": "PHI",
    "period": 3,
    "clock": "2:40",
    "yardline": 54,
    "field_x": 4,
    "field_y": null
  },
  {
    "team": "KC",
    "period": 3,
    "clock": "0:34",
    "yardline": 24,
    "field_x": -26,
    "field_y": null
  },
  {
    "team": "KC",
    "period": 4,
    "clock": "2:54",
    "yardline": 7,
    "field_x": -43,
    "field_y": null
  },
  {
    "team": "KC",
    "period": 4,
    "clock": "1:48",
    "yardline": 50,
    "field_x": 0,
    "field_y": null
  }
]
import { SUPER_BOWL_LIX_TDS } from "@sportsdataverse/examples/data";
import { toSurfaceFrame } from "@sportsdataverse/sporty";

// ESPN plays: a 0-100 yardline from the home team's goal line (Super Bowl LIX's touchdowns, Philadelphia at home).
// The frame centres the field: midfield is x = 0. `x`/`y` pick the input columns and `out` names the outputs, so the
// frame fits any table. ESPN reports no lateral position, so these rows have no `y` and field_y is null, never NaN.
toSurfaceFrame(
SUPER_BOWL_LIX_TDS.map(({ team, clock, period, yardline }) => ({ team, period, clock, yardline })),
{ from: "espn-football-0-100", x: "yardline", out: { x: "field_x", y: "field_y" } },
);

PWHL_GOALS: one game's goals​

The four goals of PWHL Boston at PWHL Montreal on 2 March 2024 (game 42; Montreal won 3-1), from HockeyTech's gameCenterPlayByPlay, as captured by sportsdataverse-py: scorer, team, period, time and the canvas position (xLocation/yLocation, pixels on the 600 × 300 canvas).

{
  "hockeytech": "HockeyTech 600x300 canvas, top-left origin -> 200x85 ft centre origin (fastRhockey hockeytech_analytics: x/3-100, 42.5-y*85/300). The canvas is stylised rather than true to scale: converted end-zone faceoff dots land at about ±67 ft and ±52 ft against regulation ±69 ft, so the converted feet are approximate",
  "PWHL Boston at Montreal, 2024-03-02 (game 42): goals": [
    "MTL Marie-Philip Poulin, P1 3:51: (90, 123) -> (-70.0, 7.7) ft",
    "MTL Mélodie Daoust, P2 4:50: (57, 105) -> (-81.0, 12.8) ft",
    "BOS Hilary Knight, P2 14:55: (470, 117) -> (56.7, 9.3) ft",
    "MTL Erin Ambrose, P3 2:29: (196, 61) -> (-34.7, 25.2) ft"
  ]
}
import { PWHL_GOALS } from "@sportsdataverse/examples/data";
import { FRAMES, toSurfaceFrame } from "@sportsdataverse/sporty";

// HockeyTech leagues report events on a 600 x 300 pixel canvas (top-left origin, y down); the hockeytech frame maps
// it onto a 200 x 85 ft rink with its centre at 0, 0. PWHL game 42's shots span x 31-573 and y 11-292.
const goals = toSurfaceFrame(PWHL_GOALS, { from: "hockeytech" });

{
hockeytech: FRAMES.hockeytech.description,
"PWHL Boston at Montreal, 2024-03-02 (game 42): goals": goals.map(
(r) =>
`${r.team} ${r.scorer}, P${r.period} ${r.time}: (${r.x}, ${r.y}) -> (${r.surface_x?.toFixed(1)}, ${r.surface_y?.toFixed(1)}) ft`,
),
};

BKN_SHOTS_2026 and NBA_LEAGUE_2026: a team's shots and the league​

The shot-chart examples' data, from the sportsdataverse-data nba_stats_shots release (shots_2026.parquet, stats.nba.com's 2025-26 shots). BKN_SHOTS_2026 is the first 2000 Brooklyn rows of the release (890 made, 968 threes), as blazing-the-nets keeps them for its own tests. NBA_LEAGUE_2026 is the league context of the 219,159 regular-season shots, computed by blazing-the-nets' own code: FG% by foot, by 3 ft and by side, and indexes of 1 ft and 1.5 ft hexagons with each zone's rate. NBA_LEAGUE_SQUARE_2026 bins the same shots into squares of a 1 ft hexagon's area with sdvplot's squarebin; nothing outside sdvplot computes squares, so it is context, not an oracle. fixtures/shots/README.md records each file's source, hash and command.

import * as Plot from "@observablehq/plot";
import { BKN_SHOTS_2026, NBA_LEAGUE_2026 } from "@sportsdataverse/examples/data";
import { shotCells, surface } from "@sportsdataverse/sdvplot/plot";
import { cellsVsDistance, cellsVsLeague, diffScale, sizeCells } from "@sportsdataverse/sdvplot/shots";

// Brooklyn's first 2000 shots of 2025-26 against the 2025-26 league, hoop at the bottom. Colour is a cell's FG% minus
// the league's; size is its attempts.
const court = surface("nba", { displayRange: "defense", rotation: 90 });
const frame = "nba-legacy-vertical";

// main: 1.5 ft hexagons against the league in the same hexagon, shrunk toward it by 25 attempts, sized by "sqrt-p95"
const main = cellsVsLeague(BKN_SHOTS_2026, NBA_LEAGUE_2026.hex15);
const mainSizes = sizeCells(main, NBA_LEAGUE_2026.hex15);
// master: 1 ft hexagons against the league at the hexagon's distance, the raw difference, sized by "linear-cap"
const master = cellsVsDistance(BKN_SHOTS_2026, NBA_LEAGUE_2026.byFoot, 10);
const masterSizes = sizeCells(master, { radius: 10, rule: "linear-cap" });
const masterScale = diffScale({ palette: "master" });

// diffScale's domain is a fraction (0.15 is 15 points): label the ticks in points, as d3's appendLegend does
const pts = (d: number): string => (d === 0 ? "0" : `${d > 0 ? "+" : "−"}${Math.abs(d * 100).toFixed(1)}`);

const panel = (caption: string, cells: Plot.Markish, scale: ReturnType<typeof diffScale>): HTMLDivElement => {
const div = document.createElement("div");
div.append(
Plot.plot({ ...court.scales, width: 420, caption, marks: [...court.marks, cells] }),
Plot.legend({ color: { ...scale.plot, label: "FG% vs league (points)", tickFormat: pts } }),
);
return div;
};

const charts = document.createElement("div");
charts.style.cssText = "display: flex; flex-wrap: wrap; gap: 16px";
charts.append(
panel(
"main: 1.5 ft hexagons against the league in the same hexagon",
shotCells(main, { r: mainSizes.r, frame, tip: true }),
diffScale(),
),
panel(
"master: 1 ft hexagons against the league at the same distance",
shotCells(master, { r: masterSizes.r, prior: 0, scale: masterScale, frame, tip: true }),
masterScale,
),
);
charts;

NFL_TEAM_EPA_2024: every team's EPA, 2024​

All 32 teams' 2024 regular season in expected points added (EPA), from nflverse's 2024 play-by-play: for each team, the number of its offensive rush and pass plays with an EPA and their summed EPA (off_plays, off_epa), and the same for the plays its defence faced (def_plays, def_epa). EPA per play is off_epa / off_plays; net EPA per play subtracts def_epa / def_plays. The rows are every row of fixtures/examples/nfl_epa_2024_reg.csv, which tools/sample-data/nfl_2024.py writes; STANDINGS' net_epa comes from the same table.

−0.15−0.10−0.050.000.050.100.15Offensive EPA/play →0.140.120.100.080.060.040.020.00−0.02−0.04−0.06−0.08↑ Better defence (defensive EPA/play allowed, reversed)
import * as Plot from "@observablehq/plot";
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague, resolveSync } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";

const nfl = await loadLeague("nfl");

// nflverse 2024 regular season, rush or pass plays: EPA per play the offence gained and the defence allowed
const teams = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
offense: t.off_epa / t.off_plays,
defense: t.def_epa / t.def_plays,
}));
// ESPN's team pages go by ESPN's abbreviation, not always nflverse's (WAS is WSH, LA is LAR). sdvplot's aliases carry
// both: resolve the nflverse code to its team, then read that team's espn_abbr.
const espnAbbr = new Map(
nfl.aliases.filter((a) => a.id_system === "espn_abbr").map((a) => [a.team_id, a.value]),
);
const espnPage = (team: string): string => {
const id = resolveSync(team, "nfl", { idSystem: "nflverse", season: 2024 }) ?? "";
return `https://www.espn.com/nfl/team/_/name/${espnAbbr.get(id)?.toLowerCase()}`;
};

Plot.plot({
width: 640,
grid: true,
// the scales' labels are what the tip shows; the reversed y axis says which way is better
x: { label: "Offensive EPA/play" },
y: { label: "Defensive EPA/play allowed", reverse: true },
marks: [
Plot.axisY({ label: "↑ Better defence (defensive EPA/play allowed, reversed)" }),
Plot.linearRegressionY(teams, { x: "offense", y: "defense", stroke: "currentColor", strokeOpacity: 0.4 }),
logos(teams, {
league: "nfl",
x: "offense",
y: "defense",
team: "team",
height: 0.07,
tip: { format: { x: ".3f", y: ".3f" } },
href: (d: { team: string }) => espnPage(d.team),
target: "_blank",
}),
],
});

KC_PHI_GAMES_2024: two teams' seasons​

Every 2024 regular-season game of the Chiefs and the Eagles, the two teams that met in Super Bowl LIX: 34 games, 17 each (they did not play each other until February). Each row is a game from nflverse games.csv (id, week, the away and home teams and their scores), taken unchanged from fixtures/examples/nfl_games_2024_reg.csv.

KCPHI
−2024681012141618↑ Point margin, 4-game average24681012141618Week →
import * as Plot from "@observablehq/plot";
import { KC_PHI_GAMES_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");

// one row per team per game, from that team's side of the score
const games = KC_PHI_GAMES_2024.flatMap((g) =>
(["KC", "PHI"] as const).flatMap((team) =>
g.home_team === team
? [{ team, week: g.week, margin: g.home_score - g.away_score }]
: g.away_team === team
? [{ team, week: g.week, margin: g.away_score - g.home_score }]
: [],
),
);
// strict: each line starts at the team's 4th game, so every point is the 4-game average the axis names (without it,
// games 1-3 would be 1-, 2- and 3-game means under the same label). The window counts games, so a bye stretches none.
const rolling = { k: 4, anchor: "end", strict: true } as const;
Plot.plot({
width: 640,
height: 320,
x: { label: "Week →" },
y: { label: "↑ Point margin, 4-game average", grid: true },
color: teamColor("nfl", { values: ["KC", "PHI"], legend: true }),
marks: [
Plot.ruleY([0]),
Plot.lineY(games, Plot.windowY(rolling, { x: "week", y: "margin", stroke: "team", tip: true })),
// the same rolling value, last week only: each team's logo labels its own line
logos(
games,
Plot.selectLast(
Plot.windowY(rolling, {
league: "nfl",
team: "team",
x: "week",
y: "margin",
z: "team",
height: 0.09,
}),
),
),
],
});

WC2018_FINAL_FRANCE_PASSES: one half of passes​

France's 75 completed passes in the first half of the 2018 World Cup final (France 4-2 Croatia, Moscow, 15 July 2018), from StatsBomb's open data (match 8658) as socceraction 1.5.3 converts it to SPADL: metres from the bottom-left corner of a 105 × 68 m pitch, every action attacking left to right. The capture is the one sportsdataverse-py commits for its socceraction tests, trimmed to France's successful first-half passes in fixtures/examples/spadl_8658_france_h1_passes.csv. StatsBomb's terms ask that work published from the data state StatsBomb as the source and show the StatsBomb logo (clause 1.4 of the user agreement), so every figure drawn from these rows carries both, as below. The logo is StatsBomb's own, from img/ in the open-data repository.

Data: StatsBomb open data (match 8658), as socceraction SPADL.
StatsBomb Data: StatsBomb open data
import * as Plot from "@observablehq/plot";
import { WC2018_FINAL_FRANCE_PASSES } from "@sportsdataverse/examples/data";
import { frameBottomLeft, surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";

// StatsBomb open data as SPADL: metres from the bottom-left corner of a 105 x 68 pitch, France attacking left to
// right. The pitch is sporty's FIFA pitch at those dimensions, so the data and the lines share one frame.
const frame = frameBottomLeft(105, 68);
const starts = toSurfaceFrame(WC2018_FINAL_FRANCE_PASSES, {
from: frame,
x: "start_x",
y: "start_y",
out: { x: "x1", y: "y1" },
});
const passes = toSurfaceFrame(starts, { from: frame, x: "end_x", y: "end_y", out: { x: "x2", y: "y2" } });
const pitch = surface("soccer", "fifa", {
updates: { pitch_length: 105, pitch_width: 68 },
arcResolution: 48,
});
Plot.plot({
...surfaceScales(pitch),
width: 760,
caption: "Data: StatsBomb open data (match 8658), as socceraction SPADL.",
marks: [
...surfaceMark(pitch),
Plot.arrow(passes, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
stroke: "#002395",
strokeWidth: 1.25,
headLength: 6,
}),
],
});