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 nflversegames.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 nflverseplayers.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 leftnullon 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.
| team | conf | division | wins | losses | ties | pf | pa | net_epa | srs_rank | qb | qb_espn_id | result_last |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| KC | AFC | West | 15 | 2 | 0 | 385 | 326 | 0.063 | 10 | Patrick Mahomes | 3139477 | L |
| LAC | AFC | West | 11 | 6 | 0 | 402 | 301 | 0.101 | 9 | Justin Herbert | 4038941 | W |
| DEN | AFC | West | 10 | 7 | 0 | 425 | 311 | 0.108 | 8 | Bo Nix | 4426338 | W |
| LV | AFC | West | 4 | 13 | 0 | 309 | 434 | -0.146 | 26 | Gardner Minshew | 4038524 | L |
| BUF | AFC | East | 13 | 4 | 0 | 525 | 368 | 0.19 | 4 | Josh Allen | 3918298 | L |
| MIA | AFC | East | 8 | 9 | 0 | 345 | 364 | -0.019 | 21 | Tua Tagovailoa | 4241479 | L |
| NYJ | AFC | East | 5 | 12 | 0 | 338 | 404 | -0.045 | 24 | Aaron Rodgers | 8439 | W |
| NE | AFC | East | 4 | 13 | 0 | 289 | 417 | 29 | Drake Maye | 4431452 | W | |
| 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.
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.
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.
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.
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.
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 dataimport * 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,
}),
],
});