Skip to main content

Observable Plot

Observable Plot is the first-class target: every sdvplot helper for it is an ordinary Plot mark or scale option, so it composes with Plot's own marks, facets and transforms. Load the league first (await loadLeague("nfl")), then pass the marks to Plot.plot like any other.

Logos at data points​

logos(rows, { league, x, y, team }) draws each row's team logo at its position; height is a fraction of the plot's height, so the logos scale with the figure.

315320325330335340345350355360365↑ pa400420440460480500520pf →
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const afc = [
{ team: "KC", pf: 385, pa: 326 },
{ team: "BUF", pf: 525, pa: 368 },
{ team: "DEN", pf: 425, pa: 311 },
];
Plot.plot({ marks: [logos(afc, { league: "nfl", x: "pf", y: "pa", team: "team", tip: true })] });
430420410400390380370360350340330320310↓ Points against300350400450500Points for →
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
Plot.plot({
grid: true,
x: { label: "Points for" },
y: { label: "Points against", reverse: true },
marks: [logos(STANDINGS, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.1 })],
});

Plot's own options and transforms​

The image marks (logos, wordmarks, headshots) take every Plot.image option sdvplot does not own (tip, href, title, fx/fy, sort, filter, dx/dy, className, clip, …) and wrap in Plot's row-preserving transforms: Plot.dodgeY and Plot.dodgeX for a beeswarm, Plot.stackY, Plot.windowY, Plot.selectLast and Plot.pointer. height stays a fraction of the frame, or of each facet's frame under fx/fy. Under a dodge, r is the collision radius in pixels (about half the drawn height): it spaces the logos and is never drawn. A transform that makes new rows (Plot.bin, Plot.group, Plot.hexbin) throws, because one image per input row cannot survive it; aggregate the rows first.

−0.15−0.10−0.05+0.00+0.05+0.10+0.15+0.20Net EPA/play →
import * as Plot from "@observablehq/plot";
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");

const teams = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
net: t.off_epa / t.off_plays - t.def_epa / t.def_plays,
}));
// height is a fraction of the frame (240 - 20 - 30 = 190 px), so each logo is 0.12 x 190 = 22.8 px tall;
// dodge's r (pixels) is half that, so neighbours just touch
Plot.plot({
width: 760,
height: 240,
marginTop: 20,
marginBottom: 30,
// a short label: the tip repeats it beside the value, and Plot cuts a long tip line
x: { label: "Net EPA/play →", tickFormat: "+.2f" },
marks: [
Plot.ruleX([0], { strokeOpacity: 0.3 }),
logos(
teams,
Plot.dodgeY({
league: "nfl",
team: "team",
x: "net",
r: 11.4,
height: 0.12,
tip: { format: { x: "+.3f" } },
}),
),
],
});
EastWestdivision300350400450500↑ Points for5101551015Wins →
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, meanLines } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");

Plot.plot({
width: 640,
height: 320,
grid: true,
x: { label: "Wins" },
y: { label: "Points for" },
marks: [
...meanLines(STANDINGS, { x: "wins", fx: "division" }),
logos(STANDINGS, {
league: "nfl",
x: "wins",
y: "pf",
team: "team",
fx: "division",
height: 0.15,
tip: true,
}),
],
});
0.080.100.120.140.160.18net_epa →
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const afc = [
{ team: "KC", net_epa: 0.063 },
{ team: "LAC", net_epa: 0.101 },
{ team: "DEN", net_epa: 0.108 },
{ team: "BUF", net_epa: 0.19 },
];
Plot.plot({ height: 160, marks: [logos(afc, Plot.dodgeY({ league: "nfl", team: "team", x: "net_epa", r: 12, height: 0.18 }))] });

Hover​

tip: true on any sdvplot Plot mark adds Plot's own tip, opt-in as in Plot. The default channels are the team for logos and wordmarks (the player id for headshots); attempts, FG%, league FG% and the shrunk difference for shotCells; the zone, and given stats its makes/attempts and FG%, for shotZones; distance, FG%, league FG% and shot share for shootingSignature. A tip object's format overrides those formats key by key. What is under the pointer is the figure's value, with an input event on each change, as for any Plot mark. A server-rendered figure carries an empty tip group that does nothing until the page runs the chart. Hover a logo below; each one also links to its team's page.

−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",
}),
],
});

Wordmarks and headshots​

wordmarks draws the team's wordmark instead, centred on its point. headshots takes a player column of ESPN ids, which need no league data.

KCBUFLACDENMIANYJLVNE024681012141618Wins →
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { wordmarks } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
Plot.plot({
marginLeft: 40,
x: { label: "Wins", domain: [0, 19] },
y: { label: null },
marks: [
Plot.barX(STANDINGS, { x: "wins", y: "team", fill: "#ccc", sort: { y: "-x" } }),
// A wordmark is centred on its point: an accessor puts the centre just past the bar's end.
wordmarks(STANDINGS, { league: "nfl", x: (s) => s.wins + 1.8, y: "team", team: "team", height: 0.07 }),
Plot.ruleX([0]),
],
});
430420410400390380370360350340330320310↓ Points against300350400450500Points for →Patrick MahomesJustin HerbertBo NixGardner MinshewJosh AllenTua TagovailoaAaron RodgersDrake Maye
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { headshots } from "@sportsdataverse/sdvplot/plot";

// ESPN player ids need no league data: headshots builds each URL from the id alone.
Plot.plot({
grid: true,
x: { label: "Points for" },
y: { label: "Points against", reverse: true },
marks: [
headshots(STANDINGS, {
league: "nfl",
x: "pf",
y: "pa",
player: "qb_espn_id",
title: "qb",
height: 0.14,
}),
],
});

Logos on an axis​

axisLogos("x", { league }) turns an axis's tick labels into logos; a value that resolves to no team keeps its text.

02468101214↑ wins
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const wins = [
{ team: "KC", wins: 15 },
{ team: "BUF", wins: 13 },
{ team: "LAC", wins: 11 },
];
Plot.plot({ marks: [Plot.barY(wins, { x: "team", y: "wins" }), axisLogos("x", { league: "nfl" })] });
−100−50050100150Point differential →
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({
x: { label: "Point differential" },
color: teamColor("nfl", { values: STANDINGS.map((s) => s.team) }),
marks: [
Plot.barX(STANDINGS, { x: (s) => s.pf - s.pa, y: "team", fill: "team", sort: { y: "-x" } }),
// The y axis's tick labels become logos; a value that resolves to no team keeps its text.
axisLogos("y", { league: "nfl", height: 0.08 }),
Plot.ruleX([0]),
],
});
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) }),
});

A colour scale of team colours​

teamColor(league, { values }) is a Plot colour-scale option that maps a team column to team colours. Plot has one colour scale for fill and stroke, so teamFill is the same function; legend: true adds a legend and which: "secondary" switches colour.

02468101214↑ winsBUFKCLACteam
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const wins = [
{ team: "KC", wins: 15 },
{ team: "BUF", wins: 13 },
{ team: "LAC", wins: 11 },
];
Plot.plot({
color: teamColor("nfl", { values: wins.map((w) => w.team) }),
marks: [Plot.barY(wins, { x: "team", y: "wins", fill: "team" })],
});
KCLACDENLVBUFMIANYJNE
050100150200250300350400450500↑ Points forBUFDENLACKCMIANYJLVNE
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamFill } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
Plot.plot({
y: { label: "Points for", grid: true },
x: { label: null },
// values: the teams this chart shows (the default domain is every team id and abbreviation in the league).
color: teamFill("nfl", { values: STANDINGS.map((s) => s.team), legend: true }),
marks: [Plot.barY(STANDINGS, { x: "team", y: "pf", fill: "team", sort: { x: "-y" } }), Plot.ruleY([0])],
});

Reference lines and a logo in the title​

meanLines and medianLines draw rules at the mean or median of x and y, per facet. titleImage puts a team logo in a figure's title.

430420410400390380370360350340330320310↓ Points against300350400450500Points for →
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, meanLines, medianLines } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
Plot.plot({
x: { label: "Points for" },
y: { label: "Points against", reverse: true },
marks: [
// Red dashed rules at the means; the medians in a second style. Both reduce per facet.
meanLines(STANDINGS, { x: "pf", y: "pa" }),
medianLines(STANDINGS, { x: "pf", y: "pa", stroke: "steelblue", strokeDasharray: "1 3" }),
logos(STANDINGS, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.09 }),
],
});

Kansas City, 2024 regular season

AllowedScored050100150200250300350400450Points →
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { titleImage } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const kc = STANDINGS.filter((s) => s.team === "KC");
const chart = Plot.plot({
height: 140,
x: { label: "Points", domain: [0, 450] },
y: { label: null },
marks: [
Plot.barX(
kc.flatMap((s) => [
{ side: "Scored", points: s.pf },
{ side: "Allowed", points: s.pa },
]),
{ x: "points", y: "side", fill: "#e31837" },
),
Plot.ruleX([0]),
],
});
// A bare <svg> needs `title`; a figure rendered with Plot.plot({ title }) keeps its own <h2>.
titleImage(chart, { image: "KC", league: "nfl", title: "Kansas City, 2024 regular season" });

Team tiers​

teamTiers(rows, { league }) returns a whole tier chart's Plot options from rows of team and tier_no. prepareTiers computes the same positions and labels without Plot, and wrapLabel and the TIER_* constants are the pieces it uses.

NFL Team Tiers

created with the #sdvplot Tiermaker

EliteVery GoodMedium
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamTiers } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nfl");
const rows = [
{ team: "BUF", tier_no: 1 },
{ team: "KC", tier_no: 1 },
{ team: "MIA", tier_no: 2 },
{ team: "NE", tier_no: 3 },
];
Plot.plot(teamTiers(rows, { league: "nfl" }));

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" }));
{
  "points": [
    "BUF: tier 1, rank 1",
    "DEN: tier 1, rank 2",
    "KC: tier 2, rank 1",
    "LAC: tier 2, rank 2",
    "MIA: tier 3, rank 1",
    "NYJ: tier 3, rank 2",
    "LV: tier 4, rank 1",
    "NE: tier 4, rank 2"
  ],
  "breakLabels": [
    "Elite",
    "Very Good",
    "Medium",
    "Bad"
  ],
  "title": "NFL Team Tiers",
  "TIERS_SUBTITLE": "created with the #sdvplot Tiermaker",
  "TIER_DESC": {
    "1": "Elite",
    "2": "Very Good",
    "3": "Medium",
    "4": "Bad",
    "5": "What are they doing?",
    "6": "",
    "7": ""
  },
  "TIER_THEMES.light": {
    "bg": "#ffffff",
    "line": "#3a3a3c",
    "text": "#1e1e1e",
    "muted": "#636366"
  },
  "wrapLabel('What are they doing?')": "What are they\ndoing?"
}
import { STANDINGS } from "@sportsdataverse/examples/data";
import {
TIERS_SUBTITLE,
TIER_DESC,
TIER_THEMES,
loadLeague,
prepareTiers,
wrapLabel,
} from "@sportsdataverse/sdvplot";

await loadLeague("nfl");
// Tier by SRS rank (ranks 1 to 8 in tier 1, 9 to 16 in tier 2, ...): teamTiers draws exactly these numbers.
const rows = STANDINGS.map((s) => ({ team: s.team, tier_no: Math.ceil(s.srs_rank / 8) }));
const t = prepareTiers(rows, "nfl", { presort: true, theme: "light" });

{
points: t.x.map((x, i) => `${t.labels[i]}: tier ${t.y[i]}, rank ${x}`),
breakLabels: t.breakLabels,
title: t.title,
TIERS_SUBTITLE,
TIER_DESC,
"TIER_THEMES.light": TIER_THEMES.light,
"wrapLabel('What are they doing?')": wrapLabel("What are they doing?"),
};

More of Plot, with sports data​

sdvplot's colours and logos mixed with Plot's own marks and transforms: Plot.differenceY shading a win probability in the leading team's colour (Kansas City never led Super Bowl LIX, so only Philadelphia's shows), Plot.windowY for rolling form with Plot.selectLast placing a logo at the end of each line (strict, so each line starts at the 4th game), Plot.density contours of shots on a court, and Plot.waffleY for a shot diet.

0%10%20%30%40%50%60%70%80%90%100%↑ Philadelphia win probability0102030405060Minutes played →
Kansas City never led: Philadelphia's win probability never fell below 54.1%, so only Philadelphia's colour draws. Data: ESPN.
import * as Plot from "@observablehq/plot";
import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { loadLeague, matchupColorsSync } from "@sportsdataverse/sdvplot";

await loadLeague("nfl");

// PHI's lead (home, above 50%) in PHI's colour, a KC lead in KC's; matchupColors picks a pair that differ from each
// other and read on white
const [phi, kc] = matchupColorsSync("PHI", "KC", { league: "nfl" }).light;
Plot.plot({
width: 640,
height: 300,
caption:
"Kansas City never led: Philadelphia's win probability never fell below 54.1%, so only Philadelphia's colour draws. Data: ESPN.",
x: { label: "Minutes played →", domain: [0, 60] },
y: { label: "↑ Philadelphia win probability", domain: [0, 1], tickFormat: "%" },
marks: [
Plot.differenceY(SUPER_BOWL_LIX_WP, {
x: "minute",
y1: 0.5,
y2: "home_wp",
positiveFill: phi,
negativeFill: kc,
fillOpacity: 0.6,
}),
Plot.ruleY([0.5], { strokeOpacity: 0.4 }),
Plot.crosshairX(SUPER_BOWL_LIX_WP, { x: "minute", y: "home_wp" }),
],
});
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,
}),
),
),
],
});
import * as Plot from "@observablehq/plot";
import { BKN_SHOTS_2026 } from "@sportsdataverse/examples/data";
import { surface } from "@sportsdataverse/sdvplot/plot";
import { toSurfaceFrame } from "@sportsdataverse/sporty";

// stats.nba.com rows (x_legacy / y_legacy, tenths of a foot from the hoop) into the court's feet. The court's
// aspectRatio 1 keeps a pixel the same distance both ways, so the kernel is round on the floor.
// (ShotRow is an interface, so spread each row into a plain object: toSurfaceFrame takes indexable rows)
const shots = toSurfaceFrame(
BKN_SHOTS_2026.map((s) => ({ ...s })),
{ from: "nba-legacy" },
);
const court = surface("nba", { displayRange: "defense", arcResolution: 48 });
Plot.plot({
...court.scales,
width: 640,
color: { scheme: "YlOrRd", type: "sqrt" },
marks: [
...court.marks,
Plot.density(shots, {
x: "surface_x",
y: "surface_y",
bandwidth: 10,
fill: "density",
fillOpacity: 0.45,
}),
Plot.density(shots, {
x: "surface_x",
y: "surface_y",
bandwidth: 10,
thresholds: 12,
stroke: "currentColor",
strokeOpacity: 0.5,
}),
],
});
0100200300400500600700↑ AttemptsRestricted areaPaint (non-RA)Mid-rangeLeft corner 3Right corner 3Above-the-break 3
import * as Plot from "@observablehq/plot";
import { BKN_SHOTS_2026 } from "@sportsdataverse/examples/data";
import { statsByZone } from "@sportsdataverse/sdvplot/shots";
import { BASKETBALL_ZONES, BASKETBALL_ZONE_LABELS } from "@sportsdataverse/sporty";

const byZone = statsByZone(BKN_SHOTS_2026);
const diet = BASKETBALL_ZONES.map((zone) => ({
zone: BASKETBALL_ZONE_LABELS[zone],
attempts: byZone[zone].attempts,
}));
Plot.plot({
width: 640,
height: 320,
x: { label: null, domain: diet.map((d) => d.zone) },
y: { label: "↑ Attempts" },
marks: [
Plot.waffleY(diet, { x: "zone", y: "attempts", unit: 10, channels: { Zone: "zone" }, tip: true }),
Plot.ruleY([0]),
],
});

In the gallery: Observable Plot.