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