Skip to main content

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

PHIKC
102030405040302010010203040504030201
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.

102030405040302010010203040504030201
team "KC" paints offensive_endzone, defensive_endzone; nfl draws sporty's football nfl surface (22 leagues have one)
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​

02468101214↑ wins
AFC West and East wins, 2024 regular season. Data: nflverse
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

EliteVery GoodMedium
data: nflverse, 2024 regular season
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.