Skip to main content

Basketball (NBA / WNBA / NCAA)

A shot chart is the basketball figure: shots in the feed's coordinates, moved onto a court drawn to the league's dimensions, often in the home team's colours. 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.

Shot chart: a court in team colours, dots, logos​

Every shot of the fourth quarter of the Lakers at the Nuggets on 24 October 2023, from stats.nba.com (see Sample data). sdvplot's surface("nba", { team }) paints the court; toSurfaceFrame moves the shots from stats.nba.com coordinates into court feet, and the defensive half is all that shows.

import * as Plot from "@observablehq/plot";
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, surface, teamColor } from "@sportsdataverse/sdvplot/plot";
import { toSurfaceFrame } from "@sportsdataverse/sporty";

await loadLeague("nba");

// stats.nba.com shots: columns x_legacy/y_legacy (the frame's defaults), tenths of a foot from the hoop.
// Every shot lands on the -x half, so draw the defensive half only: displayRange "defense".
const shots = toSurfaceFrame(NBA_SHOTS, { from: "nba-legacy" });
const court = surface("nba", { team: "DEN", displayRange: "defense" });
const at = { x: "surface_x", y: "surface_y", r: 5 } as const;
Plot.plot({
...court.scales,
width: 940,
color: teamColor("nba", { values: shots.map((s) => s.team), legend: true }),
marks: [
...court.marks,
// made shots filled in the shooting team's colour, misses white with a ring in it: both read on the paint
Plot.dot(
shots.filter((s) => s.made),
{ ...at, fill: "team", stroke: "white" },
),
Plot.dot(
shots.filter((s) => !s.made),
{ ...at, fill: "white", stroke: "team", strokeWidth: 2 },
),
logos(
shots.filter((s) => s.made && s.shot_type === "3PT Field Goal"),
{ league: "nba", x: "surface_x", y: "surface_y", team: "team", height: 0.08 },
),
],
});

From stats.nba.com coordinates to court feet​

The nba-legacy frame reads x_legacy/y_legacy (tenths of a foot from the hoop, x across the court) and returns surface_x/surface_y in court feet.

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

Standings: bars with logos on the axis​

The Pacific Division's 2023-24 wins, from stats.nba.com (see Sample data).

01020304050↑ wins
Pacific Division wins, 2023-24 regular season. Data: stats.nba.com
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) }),
});

The same court in D3 and Chart.js​

import { loadLeague } from "@sportsdataverse/sdvplot";
import { appendSurface } from "@sportsdataverse/sdvplot/d3";
import * as d3 from "d3";

await loadLeague("nba");
// An NBA court with its apron spans x -55..55 ft and y -30..30 ft; 6 px per foot.
const [width, height] = [660, 360];
const x = d3.scaleLinear([-55, 55], [0, width]);
const y = d3.scaleLinear([-30, 30], [height, 0]);
const svg = d3.select(document.createElement("div")).append("svg").attr("viewBox", [0, 0, width, height]);
appendSurface(svg, "nba", { team: "LAL", x, y });

svg.node();
The 38 fourth-quarter shots of the Lakers at the Nuggets, 24 October 2023, on the defensive half of a Nuggets-painted court: made shots filled in the team colour, misses white
import { createCanvas } from "@napi-rs/canvas";
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColor } from "@sportsdataverse/sdvplot/chartjs";
import { surface } from "@sportsdataverse/sdvplot/chartjs/surface";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
import { Chart, type ChartConfiguration, registerables } from "chart.js";

await loadLeague("nba");

// The Plot shot chart's stats.nba.com shots (x_legacy/y_legacy, tenths of a foot from the hoop), in court feet.
const shots = toSurfaceFrame(NBA_SHOTS, { from: "nba-legacy" });
const court = surface("nba", { team: "DEN", displayRange: "defense" });
// made shots filled in the shooting team's colour, misses white with a ring in it: both read on the wood and the paint
const color = teamColor(
shots.map((s) => s.team),
"nba",
);

// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given).
const config = (): ChartConfiguration<"scatter"> => ({
type: "scatter",
data: {
datasets: [
{
data: shots.map((s) => ({ x: Number(s.surface_x), y: Number(s.surface_y) })),
backgroundColor: color.map((c, i) => (shots[i]?.made ? c : "#ffffff")),
borderColor: color.map((c, i) => (shots[i]?.made ? "#ffffff" : c)),
borderWidth: 2,
pointRadius: 7,
},
],
},
// court.plugin paints the court under the datasets; court.scales keeps one foot the same length on both axes
plugins: [court.plugin],
options: {
responsive: false,
animation: false,
layout: { padding: 0 },
plugins: { legend: { display: false } },
scales: { x: { ...court.scales.x, display: false }, y: { ...court.scales.y, display: false } },
},
});
const [x0, y0, x1, y1] = court.scene.bbox;
const width = 640;
// In the browser: new Chart(canvas, config()) on a canvas of the court's aspect, as this page does.
export const browser = {
lib: "chartjs",
config,
width,
height: Math.round((width * (y1 - y0)) / (x1 - x0)),
label:
"The 38 fourth-quarter shots of the Lakers at the Nuggets, 24 October 2023, on the defensive half of a Nuggets-painted court: made shots filled in the team colour, misses white",
} as const;

Chart.register(...registerables);
const canvas = createCanvas(browser.width, browser.height);
new Chart(canvas.getContext("2d") as unknown as CanvasRenderingContext2D, config());

const png = canvas.toBuffer("image/png").toString("base64");
`<img src="data:image/png;base64,${png}" width="${canvas.width}" height="${canvas.height}" alt="${browser.label}">`;

Recolouring and resizing the court​

colorUpdates recolours any of the court's colour keys, and updates changes any dimension: here the NBA lane at its 1951-1964 width.

4 of the 26 basketball colour keys changed
import { colorKeys, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";

// Any key of colorKeys("basketball") takes a colour; the rest keep the league's defaults.
const court = surface("basketball", "nba", {
colorUpdates: {
painted_area: "#552583",
center_circle_fill: "#552583",
two_point_range: "#fdb927",
three_point_line: "#552583",
},
});

`<figure style="margin:0">${toSVG(court, { width: 560, arcs: "svg" })}<figcaption>4 of the ${colorKeys("basketball").length} basketball colour keys changed</figcaption></figure>`;
nba (16 ft lane)
updates: { lane_width: [12, 12] }
import { type BasketballParamUpdates, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";

// `updates` overrides any parameter of the league's spec; the type lists every one (and rejects typos).
// The NBA lane is 16 ft wide (with a 12 ft inner box); the 1951-1964 lane was 12 ft.
const narrow: BasketballParamUpdates = { lane_width: [12, 12] };

const draw = (label: string, updates?: BasketballParamUpdates): string =>
`<figure style="margin:0">${toSVG(surface("basketball", "nba", { displayRange: "offense", ...(updates ? { updates } : {}) }), { width: 300, arcs: "svg" })}<figcaption>${label}</figcaption></figure>`;

`<div style="display:flex;flex-wrap:wrap;gap:16px">${draw("nba (16 ft lane)")}${draw("updates: { lane_width: [12, 12] }", narrow)}</div>`;

Every basketball court​

FIBA, NBA, NBA G League, WNBA, NCAA and NFHS.