Skip to main content

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.

Patrick MahomesJustin HerbertBo NixGardner MinshewJosh AllenTua TagovailoaAaron RodgersDrake Maye
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>
);
Kansas City ChiefsLos Angeles ChargersDenver BroncosLas Vegas RaidersBuffalo BillsMiami DolphinsNew York JetsNew England Patriots
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.

ShotChart.tsx
"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}`} />;
}
280300320340360380400420440460480500520540280300320340360380400420440
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();
KCLACDENLVBUFMIANYJNE0246810121416
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).

AFC starting quarterbacks, 2024
QuarterbackTeam
3139477Patrick MahomesKC
4038941Justin HerbertLAC
4426338Bo NixDEN
4038524Gardner MinshewLV
3918298Josh AllenBUF
4241479Tua TagovailoaMIA
8439Aaron RodgersNYJ
4431452Drake MayeNE
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.