Using sdvplot with blazing-the-nets (Next/React + d3)
blazing-the-nets is a Next.js App Router site on React 19 that draws its charts with the modular d3 packages inside client components: shot charts on a court in stats.nba.com coordinates, player headshots from ESPN, team colours. Each of those maps onto an sdvplot or sporty call, and this guide shows where each one goes: the server component, the client component, or the d3 effect.
Server components: plain URLs
A server component can await loadLeague("nba") and use the synchronous lookups: logoUrlSync and headshotUrl
return plain URLs for an <img> or next/image (add sdv.nyc3.cdn.digitaloceanspaces.com and a.espncdn.com to
images.remotePatterns). HEADSHOT_ASPECT is the width-to-height ratio of an ESPN headshot, for sizing the box.
{
"espn": {
"Patrick Mahomes": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png",
"Justin Herbert": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038941.png",
"Bo Nix": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4426338.png"
},
"LeBron James (nba 2544)": "https://cdn.nba.com/headshots/nba/latest/260x190/2544.png",
"A'ja Wilson (wnba 1628932)": "https://cdn.wnba.com/headshots/wnba/latest/260x190/1628932.png",
"Shohei Ohtani (mlb 660271)": "https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/660271/headshot/67/current.png",
"Connor McDavid (nhl 8478402)": "https://assets.nhle.com/mugs/nhl/latest/8478402.png",
"HEADSHOT_ASPECT": "1.376",
"ESPN_HEADSHOT_LEAGUES": {
"nfl": "nfl",
"nba": "nba",
"wnba": "wnba",
"mlb": "mlb",
"nhl": "nhl",
"cfb": "college-football",
"mbb": "mens-college-basketball",
"wbb": "womens-college-basketball"
}
}import { STANDINGS } from "@sportsdataverse/examples/data";
import {
ESPN_HEADSHOT_LEAGUES,
HEADSHOT_ASPECT,
headshotUrl,
mlbHeadshotUrl,
nbaHeadshotUrl,
nhlHeadshotUrl,
wnbaHeadshotUrl,
} from "@sportsdataverse/sdvplot";
// ESPN player ids work in every league ESPN_HEADSHOT_LEAGUES lists; nothing is loaded or fetched.
const espn = Object.fromEntries(STANDINGS.slice(0, 3).map((s) => [s.qb, headshotUrl(s.qb_espn_id, "nfl")]));
{
espn,
// Each league's own CDN, by that league's player id.
"LeBron James (nba 2544)": nbaHeadshotUrl(2544),
"A'ja Wilson (wnba 1628932)": wnbaHeadshotUrl(1628932),
"Shohei Ohtani (mlb 660271)": mlbHeadshotUrl(660271),
"Connor McDavid (nhl 8478402)": nhlHeadshotUrl(8478402),
HEADSHOT_ASPECT: HEADSHOT_ASPECT.toFixed(3), // width / height of an ESPN headshot box
ESPN_HEADSHOT_LEAGUES,
};
Client components: the React components
TeamLogo, Wordmark, Headshot and the hooks hold state and effects, so they belong in a file that starts with
"use client", as the site's chart components already do.








import { STANDINGS } from "@sportsdataverse/examples/data";
import { Headshot } from "@sportsdataverse/sdvplot/react";
// ESPN ids render on the first pass; idSystem="gsis" renders once the gsis map has loaded.
(
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{STANDINGS.map((s) => (
<Headshot key={s.qb_espn_id} playerId={s.qb_espn_id} league="nfl" height={60} alt={s.qb} title={s.qb} />
))}
</div>
);








import { STANDINGS } from "@sportsdataverse/examples/data";
import { TeamLogo } from "@sportsdataverse/sdvplot/react";
(
<div style={{ display: "flex", gap: 8 }}>
{STANDINGS.map((s) => (
<TeamLogo key={s.team} team={s.team} league="nfl" size={40} />
))}
</div>
);
d3 inside an effect
The helpers in @sportsdataverse/sdvplot/d3 append to a selection you made, so they drop into the useEffect that
already draws a chart: select(ref.current) is the selection they take. They import from the d3 package, so add it
beside the modular ones.
Two things the effect has to do. The helpers that look teams up (appendSurface with a team, appendLogos,
appendWordmarks, teamColorScale) need loadLeague(league) in the browser too: a server component's call loads the
league on the server only, so await it inside the effect, with a flag that stops a draw after cleanup. And each helper
returns the <g> it appended, which the effect's cleanup removes: React Strict Mode runs every effect twice in
development, and without the cleanup the marks are drawn twice.
"use client";
// app/components/ShotChart.tsx: a client component, like blazing-the-nets' other charts
import { loadLeague } from "@sportsdataverse/sdvplot";
import { appendSurface } from "@sportsdataverse/sdvplot/d3";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
import { scaleLinear, select } from "d3";
import { useEffect, useRef } from "react";
/** stats.nba.com shotchartdetail rows, LOC_X / LOC_Y renamed to the nba-legacy frame's x_legacy / y_legacy. */
export type Shot = {
x_legacy: number;
y_legacy: number;
made: boolean;
};
// The defensive half with its apron, 8 px per foot: x -55..0 ft, y -30..30 ft.
const [width, height] = [440, 480];
const x = scaleLinear([-55, 0], [0, width]);
const y = scaleLinear([-30, 30], [height, 0]);
export function ShotChart({ shots, team }: { shots: readonly Shot[]; team: string }) {
const ref = useRef<SVGSVGElement>(null);
useEffect(() => {
const svg = ref.current;
if (svg === null) return;
let live = true;
let drawn: SVGGElement[] = [];
// A server component's loadLeague does not reach the browser bundle: load the league here, before the first
// helper call, or the team's colours are not there to paint the court with.
loadLeague("nba").then(() => {
if (!live) return;
const court = appendSurface(select(svg), "nba", { team, displayRange: "defense", x, y });
const dots = select(svg).append("g");
dots
.selectAll("circle")
.data(toSurfaceFrame(shots, { from: "nba-legacy" }))
.join("circle")
.attr("cx", (s) => x(s.surface_x ?? Number.NaN))
.attr("cy", (s) => y(s.surface_y ?? Number.NaN))
.attr("r", 5)
.attr("fill", (s) => (s.made ? "#1b7837" : "#b2182b"));
drawn = [court.node(), dots.node()].filter((g) => g !== null);
});
// React Strict Mode runs every effect twice in development (mount, clean up, mount): without removing what the
// effect appended, the court and the dots are drawn twice.
return () => {
live = false;
for (const g of drawn) g.remove();
};
}, [shots, team]);
return <svg ref={ref} viewBox={`0 0 ${width} ${height}`} />;
}
import { STANDINGS } from "@sportsdataverse/examples/data";
import { appendHeadshots } from "@sportsdataverse/sdvplot/d3";
import * as d3 from "d3";
const [width, height] = [640, 400];
const x = d3.scaleLinear([280, 540], [40, width - 20]);
const y = d3.scaleLinear([280, 450], [height - 30, 20]);
const svg = d3.select(document.createElement("div")).append("svg").attr("viewBox", [0, 0, width, height]);
svg
.append("g")
.attr("transform", `translate(0,${height - 30})`)
.call(d3.axisBottom(x));
svg.append("g").attr("transform", "translate(40,0)").call(d3.axisLeft(y));
// ESPN player ids need no league data (gsis ids need `await loadGsis()` and idSystem: "gsis").
appendHeadshots(
svg,
STANDINGS.map((s) => s.pf),
STANDINGS.map((s) => s.pa),
STANDINGS.map((s) => s.qb_espn_id),
{ league: "nfl", x: (v) => x(Number(v)), y: (v) => y(Number(v)), frameHeight: height, height: 0.14 },
);
svg.node();
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorScale } from "@sportsdataverse/sdvplot/d3";
import * as d3 from "d3";
await loadLeague("nfl");
const [width, height] = [640, 400];
const teams = STANDINGS.map((s) => s.team);
const color = teamColorScale("nfl", { values: teams }); // team -> primary colour; unknown -> "grey"
const x = d3.scaleBand(teams, [40, width - 20]).padding(0.2);
const y = d3.scaleLinear([0, 17], [height - 30, 20]);
const svg = d3.select(document.createElement("div")).append("svg").attr("viewBox", [0, 0, width, height]);
svg
.append("g")
.selectAll("rect")
.data(STANDINGS)
.join("rect")
.attr("x", (s) => x(s.team) ?? 0)
.attr("y", (s) => y(s.wins))
.attr("width", x.bandwidth())
.attr("height", (s) => y(0) - y(s.wins))
.attr("fill", (s) => color(s.team));
svg
.append("g")
.attr("transform", `translate(0,${height - 30})`)
.call(d3.axisBottom(x));
svg.append("g").attr("transform", "translate(40,0)").call(d3.axisLeft(y));
svg.node();
stats.nba.com shots on a court
stats.nba.com reports shots in tenths of a foot from the hoop. toSurfaceFrame(rows, { from: "nba-legacy" }) moves
them into court feet, and appendSurface draws the court (in a team's colours if you like) through the same scales.
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") }),
],
});
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 site's own charts (hexagons against the league, zones, the shooting signature and their legends) are computed
by @sportsdataverse/sdvplot/shots; Shot charts draws each one, and the circular faces too. The
linked shot dashboard links five of them through one shot distance, as master did.
Tables
renderHTMLAsync(spec, rows) in a server component returns the table as an HTML string for
dangerouslySetInnerHTML; a headshot column needs only the ESPN ids. The string starts with a Google Fonts <link>
and the theme's <style>: in a layout that already loads its fonts, pass { fonts: false }, and with many tables
pass { css: "none" } and put the theme's stylesheet in the layout once (HTML, SSR and the host
page).
| Quarterback | Team | |
|---|---|---|
![]() | Patrick Mahomes | KC |
![]() | Justin Herbert | LAC |
![]() | Bo Nix | DEN |
![]() | Gardner Minshew | LV |
![]() | Josh Allen | BUF |
![]() | Tua Tagovailoa | MIA |
![]() | Aaron Rodgers | NYJ |
![]() | Drake Maye | NE |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { renderHTMLAsync } from "@sportsdataverse/sdvtables/html";
// ESPN headshot URLs are built from the id alone: no shard to load.
const spec = defineTable<Standing>()
.columns((c) => [
c.headshot("qb_espn_id", { league: "nfl", label: "" }),
c.text("qb", { label: "Quarterback" }),
c.text("team"),
])
.title("AFC starting quarterbacks, 2024")
.build();
await renderHTMLAsync(spec, STANDINGS);
In the gallery: D3, React and Every cell kind.