react-chartjs-2
react-chartjs-2 is Chart.js as React components: <Chart type>, <Line>, <Bar>,
<Scatter> and the rest take a Chart.js config as props (data, options, plugins). Everything in
@sportsdataverse/sdvplot/chartjs is a dataset option or a plugin object, so it passes through as it is: no React
subpath, no wrapper. The pieces are the ones the Chart.js section shows:
logoPoints, wordmarkPoints and headshotPoints (with the pointImages plugin), axisLogos, logoWatermarks,
teamColor, teamFill and teamColorPalette, and the court, field or rink of @sportsdataverse/sdvplot/chartjs/surface.
They are synchronous, so load the league first (loadLeague). logoPoints, wordmarkPoints, headshotPoints and
logoWatermarks build <img> elements when called and need a DOM: without one they throw UnsupportedTargetError
(axisLogos builds its images later, when the chart updates). A Next.js client component ("use client") still
renders once on the server, so build them after mount (in a useEffect that puts the chart's data in state) or load
the chart component with next/dynamic and { ssr: false }. The examples below run in the browser only, so they build them while rendering. Tested with
react-chartjs-2 5.3.1 and Chart.js 4.5.1.
Each chart below is drawn by react-chartjs-2 in your browser. The code also hands the same config to new Chart(...)
in Node, on @napi-rs/canvas with loadImage: that PNG is what the page
shows before (or without) JavaScript.
Register Chart.js, not sdvplot
react-chartjs-2 draws with what Chart.js has registered, so register its controllers, elements and scales once
(ChartJS.register(...registerables), or the pieces you use). That is global: it affects every chart on the page.
sdvplot's plugins need no registration. Pass them to each chart in the plugins prop; nothing of sdvplot's is left
registered when the chart unmounts. ChartJS.register(pointImages) works too, but then every chart on the page runs it.
Logos as points, updated in place
logoPoints(teams, { league, radius }) returns the dataset's pointStyle (one logo per point, in data order) and
pointRadius; pointImages redraws the chart as each logo loads. On a prop change react-chartjs-2 does not rebuild the
chart: it merges each dataset into the one with the same label, swaps data.labels, copies options over and calls
chart.update(). So give each dataset a label, and a new set of teams brings its own logos to the same chart, with
none of the old ones left behind. Pick the other division:
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type PointOptions, logoPoints, pointImages } from "@sportsdataverse/sdvplot/chartjs";
import { Chart as ChartJS, registerables } from "chart.js";
import { type ReactElement, useState } from "react";
import { Chart, type ChartProps } from "react-chartjs-2";
await loadLeague("nfl");
// react-chartjs-2 draws with what Chart.js has registered: register its controllers, elements and scales once. This is
// global; sdvplot's plugins are not registered, they go to each chart through the `plugins` prop.
ChartJS.register(...registerables);
// Each division's teams, picked by id from the 2024 standings
const DIVISIONS = { West: ["KC", "LAC", "DEN", "LV"], East: ["BUF", "MIA", "NYJ", "NE"] } as const;
type Division = keyof typeof DIVISIONS;
const rowsOf = (division: Division) =>
DIVISIONS[division].map((team) => {
const row = STANDINGS.find((s) => s.team === team);
if (row === undefined) throw new Error(`no 2024 standings row for ${team}`);
return row;
});
// <Chart>'s props are a Chart.js config: react-chartjs-2 hands them to new Chart(canvas, ...), as Node does below. A
// browser loads each logo itself; Node passes `loadImage`.
const config = (division: Division, o: Pick<PointOptions, "loadImage"> = {}): ChartProps<"scatter"> => {
const rows = rowsOf(division);
return {
type: "scatter",
data: {
datasets: [
{
// react-chartjs-2 updates a dataset in place by its label: a new division swaps the points and their logos
label: "AFC",
data: rows.map((s) => ({ x: s.pf, y: s.pa })),
...logoPoints(
rows.map((s) => s.team),
{ league: "nfl", radius: 20, ...o },
),
},
],
},
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { min: 280, max: 540, title: { display: true, text: "Points for" } },
// fewer points against is better: reversed, so the best teams sit top right
y: { min: 280, max: 440, reverse: true, title: { display: true, text: "Points against" } },
},
},
// read once, when react-chartjs-2 creates the chart: pointImages redraws it as each logo lands, on every update too
plugins: [pointImages],
};
};
// Client-only: config() builds <img> point styles while rendering, which needs a DOM (in Next.js, build them in a
// useEffect or load this component with next/dynamic and { ssr: false }).
function AfcScatter(): ReactElement {
const [division, setDivision] = useState<Division>("West");
return (
<>
<fieldset>
<legend>AFC division</legend>
{(Object.keys(DIVISIONS) as Division[]).map((d) => (
<button key={d} type="button" aria-pressed={d === division} onClick={() => setDivision(d)}>
AFC {d}
</button>
))}
</fieldset>
<Chart {...config(division)} width={640} height={400} />
</>
);
}
// In the browser: <AfcScatter />; each button re-renders the same <Chart> with the other division's rows.
export const browser = {
lib: "react-chartjs-2",
chart: () => <AfcScatter />,
width: 640,
height: 400,
label:
"Team logos at 2024 points for against points against, the AFC West (Kansas City, the Chargers, Denver, Las Vegas) or, at a button, the AFC East (Buffalo, Miami, the Jets, New England)",
} as const;
// In Node the image helpers load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas. Keep the
// promises: once they settle, pointImages has redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
ChartJS.defaults.font.family = "Source Sans 3";
const canvas = createCanvas(browser.width, browser.height);
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new ChartJS(ctx, config("West", { loadImage: load }));
await Promise.all(loads);
const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="400" alt="${browser.label}">`;
A logo at the end of each line
A line dataset takes one logo style, repeated for every point, and a pointRadius array that is zero except at the
last point. teamColor(team, league) colours the line, and { alpha } gives a translucent copy.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type PointOptions, logoPoints, pointImages, teamColor } from "@sportsdataverse/sdvplot/chartjs";
import { Chart as ChartJS, registerables } from "chart.js";
import { Chart, type ChartProps } from "react-chartjs-2";
await loadLeague("nfl");
ChartJS.register(...registerables); // once, globally: Chart.js's controllers, elements and scales
// Each team picked by id from the week-by-week margins (null is the bye week); the line is their running sum.
const teams = ["KC", "LAC", "DEN", "LV"];
const series = teams.map((team) => {
const season = AFC_WEEKS_2024.find((s) => s.team === team);
if (season === undefined) throw new Error(`no 2024 weeks for ${team}`);
const points: { x: number; y: number }[] = [];
for (const [i, m] of season.margins.entries())
points.push({ x: i + 1, y: (points.at(-1)?.y ?? 0) + (m ?? 0) });
return { team, points };
});
// <Chart>'s props are a Chart.js config: react-chartjs-2 hands them to new Chart(canvas, ...), as Node does below.
const config = (o: Pick<PointOptions, "loadImage"> = {}): ChartProps<"line"> => ({
type: "line",
data: {
datasets: series.map(({ team, points }) => ({
label: team,
data: points,
borderWidth: 3,
borderColor: teamColor(team, "nfl"),
backgroundColor: teamColor(team, "nfl", { alpha: 0.5 }),
// one logo style, repeated per point, drawn only at the line's end
pointStyle: logoPoints([team], { league: "nfl", radius: 14, ...o }).pointStyle,
pointRadius: points.map((_, i) => (i === points.length - 1 ? 14 : 0)),
clip: false, // the end logos may cross the chart area's edge; the layout padding leaves them room
})),
},
options: {
responsive: false,
animation: false,
layout: { padding: { top: 16, right: 18 } },
plugins: { legend: { display: false } },
scales: {
x: { type: "linear", min: 1, max: 18, ticks: { stepSize: 1 }, title: { display: true, text: "Week" } },
y: { title: { display: true, text: "Point differential, season to date" } },
},
},
plugins: [pointImages],
});
// In the browser: <Chart {...config()} /> on a 640 x 400 canvas.
export const browser = {
lib: "react-chartjs-2",
chart: () => <Chart {...config()} width={640} height={400} />,
width: 640,
height: 400,
label:
"The 2024 AFC West's point differential summed week by week in team colours, each line ending in its team's logo: Denver +114, the Chargers +101, Kansas City +59, Las Vegas -125",
} as const;
// In Node the image helpers load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas. Keep the
// promises: once they settle, pointImages has redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
ChartJS.defaults.font.family = "Source Sans 3";
const canvas = createCanvas(browser.width, browser.height);
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new ChartJS(ctx, config({ loadImage: load }));
await Promise.all(loads);
const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="400" alt="${browser.label}">`;
Logo ticks on a dark background
axisLogos("x", { league, size }) paints a category axis's team labels as the teams' logos and blanks their text
(variant: "dark" takes a team's logo for dark backgrounds where it has one). teamColorPalette(league, teams, { background }) gives one colour per team that keeps 3:1 contrast against the background and stays apart from the
others (CIE76 ΔE 20), as every sdvplot adapter's teamColorPalette does; teamColor gives the teams' own colours,
and Denver's navy and the Raiders' black sink into a dark chart (here they take their orange and silver). Chart.js
leaves the canvas transparent, so a small beforeDraw plugin paints the background.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type AxisLogoOptions, axisLogos, teamColorPalette } from "@sportsdataverse/sdvplot/chartjs";
import { Chart as ChartJS, type Plugin, registerables } from "chart.js";
import { Chart, type ChartProps } from "react-chartjs-2";
await loadLeague("nfl");
ChartJS.register(...registerables); // once, globally: Chart.js's controllers, elements and scales
// Each team picked by id from the week-by-week margins (null is the bye); its season's point differential is their sum.
const teams = ["KC", "LAC", "DEN", "LV"];
const diffs = teams.map((team) => {
const season = AFC_WEEKS_2024.find((s) => s.team === team);
if (season === undefined) throw new Error(`no 2024 weeks for ${team}`);
return season.margins.reduce((n: number, m) => n + (m ?? 0), 0);
});
const background = "#181a1b";
// every bar keeps 3:1 against the background and stays apart from the others: Denver's navy and the Raiders' black,
// as teamColor gives them, would sink into it (each takes its other colour, orange and silver)
const colors = teamColorPalette("nfl", teams, { background });
const ink = "#e3e3e3";
// Chart.js leaves the canvas transparent: paint the background first, so a saved PNG is dark too
const backdrop: Plugin<"bar"> = {
id: "backdrop",
beforeDraw: ({ ctx, width, height }) => {
ctx.save();
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
ctx.restore();
},
};
// <Chart>'s props are a Chart.js config: react-chartjs-2 hands them to new Chart(canvas, ...), as Node does below.
const config = (o: Pick<AxisLogoOptions, "loadImage"> = {}): ChartProps<"bar"> => ({
type: "bar",
data: { labels: teams, datasets: [{ label: "differential", data: diffs, backgroundColor: colors }] },
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { color: ink } },
y: {
grid: { color: "#3a3d3f" },
ticks: { color: ink },
title: { display: true, text: "Point differential", color: ink },
},
},
},
// the team axis' labels painted as each team's logo, 32 px tall, their text blanked. These are the default
// (light-background) marks on purpose: the dark variants (`variant: "dark"`) have no offline fixture for the gate
plugins: [backdrop, axisLogos("x", { league: "nfl", size: 32, ...o })],
});
// In the browser: <Chart {...config()} /> on a 640 x 360 canvas.
export const browser = {
lib: "react-chartjs-2",
chart: () => <Chart {...config()} width={640} height={360} />,
width: 640,
height: 360,
label:
"The 2024 AFC West's point differentials on a dark background in team colours, each bar labelled by its team's logo: Kansas City +59, the Chargers +101, Denver +114, Las Vegas -125",
} as const;
// In Node the image helpers load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas. Keep the
// promises: once they settle, axisLogos has redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
ChartJS.defaults.font.family = "Source Sans 3";
const canvas = createCanvas(browser.width, browser.height);
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new ChartJS(ctx, config({ loadImage: load }));
// after new Chart(...): axisLogos asks for its images while the chart is first laid out
await Promise.all(loads);
const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="360" alt="${browser.label}">`;
What follows a prop change, and what needs a key
react-chartjs-2 reads plugins once, when it creates the chart; a new plugin list on a later render is ignored, so a
plugin built inline (plugins={[axisLogos("x", o)]}) is harmless, but its new copy never runs.
pointImages,axisLogosand the colours follow the data.pointImageslooks at the datasets' point styles on every draw.axisLogosreadsdata.labelson every update and wires its tick callback onto each fresh copy of the options, once: new labels get their logos, without the tick padding growing.logoWatermarks(teams)andsurface(league)keep what they were built with. When the teams or the league change, give<Chart>akeythat changes with them, so React mounts a new chart with the new plugin: for a win-probability line,key={teams.join()}besideplugins={[logoWatermarks(teams, { league: "nfl" })]}(a joined key with a separator:home + awaywould give "KC" + "PHI" and "KCP" + "HI" the same key). react-chartjs-2'sredrawprop is not the same: it destroys the chart on every render and builds a new one on asetTimeout, so the ref isnullin between and nothing is updated in place.useMemokeeps a plugin from being rebuilt on each render (surfacebuilds a whole scene), but it does not make a new one reach the chart: thekeydoes that.- On unmount react-chartjs-2 destroys the chart, and the plugins drop any image load they were still waiting on.
React's
StrictMode(mount, unmount, mount) leaves one chart.
The tests in packages/sdvplot/test/chartjs/react-chartjs-2.test.tsx hold all of this: a first render, prop updates
of the same component (new teams, new labels, new colours, with fresh or memoized options), the key rebuild, StrictMode and unmount.
In the gallery: react-chartjs-2.