Skip to main content

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.

015304560
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.

1612180481216
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.

270340410480540280325370415450
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.