Recharts
Recharts draws a chart from React components, and lets a series or an axis hand each
point or tick to a function of your own. @sportsdataverse/sdvplot/recharts gives you those functions: team logos and
player headshots as a series' dot or shape, team logos as an axis' tick, and team colours for each series. It
never imports recharts (install recharts 3.8 or later beside it, an optional peer), and it is synchronous, so load the
league first (loadLeague). The court, field or rink backdrop is a component, <SurfaceBackdrop>, in
@sportsdataverse/sdvplot/react. Each chart below is drawn by Recharts in your browser.
Logos on a category axis, and dark mode
logoTick({ league, size }) is an axis' tick: each category that names a team draws the team's logo, size px
tall, where its label would be (under a bottom axis, left of a left one). A category that resolves to no logo keeps its
text label, as the ECharts adapter does. teamColorPalette(league, teams, { background }) gives one colour per team;
with background, a colour under 3:1 contrast against it gives way to the team's other colour, or is lightened (on a
dark background) or darkened until it reads, and a colour that would then sit too close to an earlier team's (CIE76
ΔE under 20, as the Lakers' and the Warriors' golds on a dark background) takes the team's other colour, or the nearest
colour to its own (lightness, hue or chroma stepped) that tells the two apart and still reads. If no candidate does
both, it keeps the readable colour and warns once, so ΔE 20 is a target, not a guarantee.
import { NBA_STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoTick, teamColorPalette } from "@sportsdataverse/sdvplot/recharts";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
await loadLeague("nba");
// The Pacific Division, 2023-24 (stats.nba.com): `team` is the nickname ("Lakers"), which sdvplot resolves.
const background = "#181a1b";
const teams = [...NBA_STANDINGS].sort((a, b) => b.wins - a.wins);
// with `background`, a team colour too dark to read on it (3:1) gives way to its other colour, or is lightened
const colors = teamColorPalette(
"nba",
teams.map((t) => t.team),
{ background },
);
(
<div style={{ background, padding: 8, width: 520 }}>
<BarChart width={520} height={320} data={teams} margin={{ top: 16, right: 16, bottom: 24, left: 0 }}>
<CartesianGrid stroke="#3a3d3e" vertical={false} />
<XAxis dataKey="team" interval={0} tickLine={false} tick={logoTick({ league: "nba", size: 36 })} />
<YAxis domain={[0, 60]} stroke="#c8c6c3" />
<Bar
dataKey="wins"
isAnimationActive={false}
shape={(p: { x?: number; y?: number; width?: number; height?: number; index?: number }) => (
<rect x={p.x} y={p.y} width={p.width} height={p.height} fill={colors[p.index ?? 0]} />
)}
/>
</BarChart>
</div>
);
A logo at the end of each line
logoDot({ team, league, size }) is a series' dot: each point draws the logo of the team in its data row's team
column, centred on the point. last: true draws only the series' final point, which turns a trend line's last value
into an end-dot. It needs the series' points, which Recharts hands a <Line> or <Area> dot but not a <Scatter>
shape: there every point is drawn, with one warning. A team with no logo draws nothing and warns once. size is a
height in px (1 or more), not a fraction of the plot as the spec adapters' height is: a fraction throws.
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoDot, teamColorPalette } from "@sportsdataverse/sdvplot/recharts";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
await loadLeague("nfl");
// Wins so far after each week's game (a positive margin is a win), from the 2024 results; the bye week has no point.
const west = AFC_WEEKS_2024.filter((t) => ["KC", "LAC", "DEN", "LV"].includes(t.team));
const colors = teamColorPalette(
"nfl",
west.map((t) => t.team),
);
const series = west.map(({ team, margins }, i) => {
let wins = 0;
return {
team,
color: colors[i] ?? "#808080",
rows: margins.flatMap((m, week) => {
if (m === null) return [];
wins += m > 0 ? 1 : 0;
return [{ week: week + 1, wins, team }];
}),
};
});
(
<LineChart width={560} height={340} margin={{ top: 20, right: 40, bottom: 20, left: 0 }}>
<CartesianGrid stroke="#e2e2e2" />
<XAxis dataKey="week" type="number" domain={[1, 18]} ticks={[1, 6, 12, 18]} />
<YAxis domain={[0, 16]} />
{series.map((s) => (
<Line
key={s.team}
data={s.rows}
dataKey="wins"
stroke={s.color}
strokeWidth={2}
isAnimationActive={false}
// `last` draws only each line's final point: the team's logo at its season's win total
dot={logoDot({ team: "team", league: "nfl", size: 28, last: true })}
/>
))}
</LineChart>
);
Headshots as points
headshotDot({ player, league, size }) draws each row's ESPN headshot (600:436) instead; give it to a <Scatter> as
its shape. logoDot works there too. An id with no headshot draws nothing and warns once.
import { STANDINGS } from "@sportsdataverse/examples/data";
import { headshotDot } from "@sportsdataverse/sdvplot/recharts";
import { CartesianGrid, Scatter, ScatterChart, XAxis, YAxis } from "recharts";
// ESPN headshot URLs are built from the id alone, so no league data has to load first.
(
<ScatterChart width={520} height={360} margin={{ top: 24, right: 32, bottom: 24, left: 8 }}>
<CartesianGrid stroke="#e2e2e2" />
<XAxis dataKey="pf" type="number" domain={[270, 540]} name="Points for" />
<YAxis dataKey="pa" type="number" domain={[280, 450]} name="Points against" reversed />
<Scatter
data={[...STANDINGS]}
isAnimationActive={false}
shape={headshotDot({ player: "qb_espn_id", league: "nfl", size: 40 })}
/>
</ScatterChart>
);
A court under the shots
<SurfaceBackdrop league team displayRange /> is a child of the chart, placed before the series so it is drawn under
them (Recharts 3 renders any element there; <Customized> is deprecated). It draws the surface in the axes' units, so
both axes must be type="number" with a numeric domain in the surface's units: the surface's bbox (sporty's
surface(...).bbox) fills the plot area, any other range in those units shows part of it. Reversed and log axes are not
supported: on them, and on a category axis, it draws nothing and warns once. recharts and @sportsdataverse/sporty load when it first renders,
never with sdvplot/react, and a missing one throws OptionalDependencyError then. On the server it draws nothing; it
appears once the page's scripts run.
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { SurfaceBackdrop } from "@sportsdataverse/sdvplot/react";
import { teamColorPalette } from "@sportsdataverse/sdvplot/recharts";
import { surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { Scatter, ScatterChart, XAxis, YAxis } from "recharts";
await loadLeague("nba");
// 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" }).map((s) => ({
x: Number(s.surface_x),
y: Number(s.surface_y),
team: s.team,
made: s.made,
}));
// the axes span the surface's bbox, so the court fills the plot area and one foot is one foot on both axes
const [x0, y0, x1, y1] = surface("basketball", "nba", { displayRange: "defense" }).bbox;
const width = 480;
const height = Math.round((width * (y1 - y0)) / (x1 - x0));
const [lal, den] = teamColorPalette("nba", ["LAL", "DEN"]);
const byTeam = (team: string, made: boolean) => shots.filter((s) => s.team === team && s.made === made);
(
<ScatterChart width={width} height={height} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<XAxis type="number" dataKey="x" domain={[x0, x1]} hide />
<YAxis type="number" dataKey="y" domain={[y0, y1]} hide />
<SurfaceBackdrop league="nba" team="DEN" displayRange="defense" />
{/* made shots filled in the shooting team's colour, misses white with a ring in it */}
<Scatter data={byTeam("LAL", true)} fill={lal} isAnimationActive={false} />
<Scatter
data={byTeam("LAL", false)}
fill="#ffffff"
stroke={lal}
strokeWidth={2}
isAnimationActive={false}
/>
<Scatter data={byTeam("DEN", true)} fill={den} isAnimationActive={false} />
<Scatter
data={byTeam("DEN", false)}
fill="#ffffff"
stroke={den}
strokeWidth={2}
isAnimationActive={false}
/>
</ScatterChart>
);
Images that travel with the file
embedSources(urls) fetches each logo once and maps it to a data URI; pass the map as embed and every <image>
carries its bytes instead of a link, so a copy of the chart's SVG shows the logos offline.
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, logoUrlSync } from "@sportsdataverse/sdvplot";
import { embedSources, logoDot } from "@sportsdataverse/sdvplot/recharts";
import { Scatter, ScatterChart, XAxis, YAxis } from "recharts";
await loadLeague("nfl");
// The AFC West's two playoff teams of 2024.
const rows = STANDINGS.filter((s) => s.division === "West" && s.wins >= 11).map((s) => ({ ...s }));
// embedSources fetches each distinct URL once and maps it to a data URI; with `embed`, each <image> carries its
// bytes instead of a link, so the chart's SVG is one self-contained file.
const embed = await embedSources(rows.flatMap((r) => logoUrlSync(r.team, "nfl") ?? []));
(
<ScatterChart width={420} height={320} margin={{ top: 24, right: 32, bottom: 24, left: 8 }}>
<XAxis dataKey="pf" type="number" domain={[370, 420]} name="Points for" />
<YAxis dataKey="pa" type="number" domain={[290, 340]} name="Points against" />
<Scatter
data={rows}
isAnimationActive={false}
shape={logoDot({ team: "team", league: "nfl", size: 64, embed })}
/>
</ScatterChart>
);
Reading back what was drawn
Every image carries the data-sdv-* attributes that drawnMarks from @sportsdataverse/sdvplot/testing reads, so a
test can render the chart (jsdom, or a browser) and check which teams were drawn where. Recharts hands a render prop
pixels and no plot height, so a mark's x, y and height there read in px, not in data units and as a fraction of
the plot as the spec adapters' do.