Football (NFL / college)
Football plays are reported against the yardline, so a play map needs the field and a frame that puts each yardline in place; team colours go on the end zones. Each figure on this page is drawn by the code under it, and the same code runs in CI on every pull request; in Node, see Node and SSR.
Play map: a field in team colours, dots, logos
Super Bowl LIX's six offensive touchdowns, each at the line of scrimmage it started from, from ESPN's game summary (see Sample data).
import * as Plot from "@observablehq/plot";
import { SUPER_BOWL_LIX_TDS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, surface, teamColor } from "@sportsdataverse/sdvplot/plot";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
await loadLeague("nfl");
// ESPN plays: `yardline` is a 0-100 yardline from the home team's goal line; the frame spans the whole field.
// ESPN reports where along the field a play starts, not across it, so every play sits on the middle (y = 0).
const plays = toSurfaceFrame(
SUPER_BOWL_LIX_TDS.map((p) => ({ ...p, y: 0 })),
{ from: "espn-football-0-100", x: "yardline" },
);
const field = surface("nfl", { team: "PHI" });
Plot.plot({
...field.scales,
width: 940,
color: teamColor("nfl", { values: plays.map((s) => s.team), legend: true }),
marks: [
...field.marks,
Plot.dot(plays, { x: "surface_x", y: "surface_y", fill: "team", stroke: "white", r: 5 }),
logos(plays, { league: "nfl", x: "surface_x", y: "surface_y", team: "team", height: 0.07 }),
],
});
ESPN yardlines
ESPN plays carry a 0-100 yardline, measured from the home team's goal line, and no position across the field; the
espn-football-0-100 frame centres the field so that midfield is x = 0, and passes a y through when a source has
one.
[
{
"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" } },
);
A field in team colours
team paints both end zones in the primary colour; colorUpdates overrides any colour key on top of that.
import * as Plot from "@observablehq/plot";
import { loadLeague, teamColorsSync } from "@sportsdataverse/sdvplot";
import { SURFACES, SURFACE_BASE, colorUpdates, surface } from "@sportsdataverse/sdvplot/plot";
await loadLeague("nfl");
const red = teamColorsSync("nfl", "KC") ?? SURFACE_BASE.football;
const gold = teamColorsSync("nfl", "KC", { which: "secondary" }) ?? SURFACE_BASE.football;
// What `team` paints, keyed by sporty colour key: both end zones in the primary colour.
const painted = colorUpdates("football", red, gold);
// colorUpdates overrides any key on top of that: here the defensive end zone takes the secondary colour.
const field = surface("nfl", { team: "KC", colorUpdates: { defensive_endzone: gold }, centerLogo: true });
Plot.plot({
...field.scales,
width: 800,
caption: `team "KC" paints ${Object.keys(painted).join(", ")}; nfl draws sporty's ${SURFACES.nfl?.join(" ")} surface (${Object.keys(SURFACES).length} leagues have one)`,
marks: field.marks,
});
Standings: bars with logos on the axis
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos, teamColor } from "@sportsdataverse/sdvplot/plot";
await loadLeague("nfl");
Plot.plot({
height: 300,
caption: "AFC West and East wins, 2024 regular season. Data: nflverse",
marks: [
Plot.barY(STANDINGS, { x: "team", y: "wins", fill: "team", sort: { x: "-y" } }),
axisLogos("x", { league: "nfl", height: 0.12 }),
],
color: teamColor("nfl", { values: STANDINGS.map((s) => s.team) }),
});
Team tiers
teamTiers takes rows of team plus tier_no (and optionally tier_rank); here the tiers come from 2024 wins.
NFL Team Tiers
created with the #sdvplot Tiermaker
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamTiers } from "@sportsdataverse/sdvplot/plot";
await loadLeague("nfl");
// Tiers by 2024 regular-season wins: 13 or more, 8 to 12, fewer than 8.
const rows = STANDINGS.map((s) => ({ team: s.team, tier_no: s.wins >= 13 ? 1 : s.wins >= 8 ? 2 : 3 }));
Plot.plot(teamTiers(rows, { league: "nfl", caption: "data: nflverse, 2024 regular season" }));
Every football field
NFL, NCAA, CFL, and the NFHS 11-player, 9-player, 8-player and 6-player fields.