TanStack Charts
TanStack Charts is a grammar of graphics for TypeScript: marks (dot, lineY, barY,
...) read your rows through channels, defineChart puts them over shared scales, and a renderer draws the resulting
keyed scene, as SVG by default, with thin adapters for React (@tanstack/charts/react), Svelte
(@tanstack/charts/svelte), the plain DOM (mountChart from @tanstack/charts/dom) and more.
@sportsdataverse/sdvplot/tanstack builds the pieces a chart of teams needs: team colours as the chart's color
option, and team logos, wordmarks and headshots as dot marks filled with their image. It never imports TanStack
Charts (install @tanstack/charts 1.1.0 or later beside it, an optional peer); you hand it TanStack's dot and
renderChartSvg. Every piece can be built anywhere, a server included. It is synchronous: load the league first
(loadLeague). The charts below are React examples, drawn by TanStack in your browser.
Team colours
teamColorScale(league, teams) returns TanStack's color option for those teams, { domain, range }: the domain is
the team values exactly as your data holds them (each once, in order) and the range their colours. Put the team field
on a mark's z (or color) channel and give the chart color: teamColorScale(...): each team draws in its own
colour, and a colorLegend lists them in this order (spread the scale and add legend: colorLegend(...)). TanStack
matches a value by its type as well as its value, so 12 and "12" are two keys: pass a numeric team column (ESPN
ids, say) as numbers. teamColorPalette returns the colours alone, for anything that takes them by position, such as a
theme.palette.
Pass background (a hex colour) for a dark chart: a team colour under 3:1 contrast against it is swapped for the
team's other colour or lifted until it reads, and the colours are kept apart from each other. TanStack's default
theme paints no background (it is transparent) and draws axes and text in currentColor, so dark mode is the card's
CSS: give the container its background and color, and pass that background to teamColorScale.
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoDots, teamColorScale, withPatterns } from "@sportsdataverse/sdvplot/tanstack";
import { colorLegend, defineChart, dot, lineY } from "@tanstack/charts";
import { Chart } from "@tanstack/charts/react";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { renderChartSvg } from "@tanstack/charts/svg";
await loadLeague("nfl");
// each team picked by id from the week-by-week score margins (null is the bye week); the line is their running sum
const TEAMS = ["BUF", "MIA", "NYJ", "NE"];
const rows = TEAMS.flatMap((team) => {
const season = AFC_WEEKS_2024.find((s) => s.team === team);
if (season === undefined) throw new Error(`no 2024 weeks for ${team}`);
let total = 0;
return season.margins.map((m, i) => {
total += m ?? 0;
return { team, week: i + 1, diff: total };
});
});
// each line in its team's colour, keyed by the team value the `z` channel reads
const color = teamColorScale("nfl", TEAMS);
// one logo per team, at its week-18 point
const ends = logoDots(
rows.filter((r) => r.week === 18),
{ x: "week", y: "diff", team: "team", league: "nfl", size: 28 },
);
(
<Chart
definition={defineChart({
marks: [
lineY(rows, { id: "margin", x: "week", y: "diff", z: "team" }),
...ends.map((d) => dot(d.data, d.options)),
],
scales: {
x: { scale: scaleLinear, axis: { label: "week" } },
y: { scale: scaleLinear, nice: true, grid: true, axis: { label: "point differential" } },
},
color: { ...color, legend: colorLegend({ label: "team" }) },
})}
renderSvg={withPatterns(renderChartSvg, ends)}
idPrefix="afc-east"
width={640}
height={400}
ariaLabel={`The 2024 AFC East's running point differential by week: ${TEAMS.map((t) => `${t} ${rows.find((r) => r.team === t && r.week === 18)?.diff}`).join(", ")} after week 18`}
style={{ background: "#ffffff", color: "#1c1e21" }}
/>
);
Logos as pattern-filled dots
TanStack Charts has no image mark: its scene is made of groups, rules, lines, areas, dots, rectangles and labels.
sdvplot draws each logo as a TanStack dot filled with an SVG <pattern> that holds the logo image. Two pieces
do it: logoDots(rows, o) gives the dots, which you map to marks with TanStack's own dot (marks: logos.map((d) => dot(d.data, d.options))), and withPatterns(renderChartSvg, logos) is the chart's renderSvg. The
examples below show both in full.
logoDots(rows, { x, y, team, league })returns one entry per logo: the rows that draw it (each whole row, withsdv_fields added) and thedotoptions. TanStack paints a dot mark'sfillonce for the whole mark, so each logo gets its own mark,fill: "url(#<its pattern>)". Each row's logo issizepx tall (default 24), centred on the row'sxandyfields read through the chart's own scales, so it sits exactly where a point of that row would. The dot's radius is the half-diagonal of the logo's box, so the circle never cuts the logo.withPatterns(renderChartSvg, logos, ...more)is the chart'srenderSvg, TanStack's public hook for its SVG serializer (the React and Svelte<Chart>andmountChartall take it). It writes each pattern into the chart's own SVG, in a<defs>of its own, scoped by the chart'sidPrefixas TanStack scopes its gradients. Build it once per set of dots (a module constant, oruseMemo).wordmarkDotsdraws wordmarks at their own width, andheadshotDots(rows, { x, y, player, league })ESPN headshots by player id (idSystem: "gsis"for nflverse ids, afterloadGsis()).sizeis in px; a fraction such as0.1is refused with anInputError.alphasets the fill opacity.- A row whose team has no logo (or whose gsis player id has no headshot), or with no
xory, is left out with one warning; nothing draws a broken image. ESPN headshot URLs are built from the player id without checking that ESPN has a photo for it: an id with none keeps its dot, unpainted, and no warning is given.
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoDots, withPatterns } from "@sportsdataverse/sdvplot/tanstack";
import { defineChart, dot } from "@tanstack/charts";
import { Chart } from "@tanstack/charts/react";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { renderChartSvg } from "@tanstack/charts/svg";
import { tooltip } from "@tanstack/charts/tooltip";
await loadLeague("nfl");
// EPA per play from the season totals (nflverse play by play, rushes and passes); every team, so nothing to pick
if (NFL_TEAM_EPA_2024.length !== 32) throw new Error(`${NFL_TEAM_EPA_2024.length} teams, not 32`);
const rows = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
offense: t.off_epa / t.off_plays,
defense: t.def_epa / t.def_plays,
}));
// one dot mark per team, each filled with its logo, 26 px tall and centred on its (offense, defense)
const logos = logoDots(rows, { x: "offense", y: "defense", team: "team", league: "nfl", size: 26 });
const perPlay = (v: unknown) => Number(v).toFixed(2);
(
<Chart
definition={defineChart(
{
marks: logos.map((d) => dot(d.data, d.options)),
scales: {
x: {
scale: scaleLinear,
nice: true,
grid: true,
axis: { label: "offence: EPA per play", ticks: { format: perPlay } },
},
// a defence wants negative EPA: the best defences sit at the top
y: {
scale: scaleLinear,
nice: true,
reverse: true,
grid: true,
axis: { label: "defence: EPA per play allowed", ticks: { format: perPlay } },
},
},
},
// the built-in tooltip: hover or focus a logo for its two values
{ tooltip },
)}
// the logos' patterns, written into the chart's own svg
renderSvg={withPatterns(renderChartSvg, logos)}
idPrefix="epa-logos"
width={640}
height={480}
ariaLabel={`The ${rows.length} NFL teams of 2024 by offensive EPA per play and defensive EPA per play allowed (the best defences at the top), each drawn as its logo`}
style={{ background: "#ffffff", color: "#1c1e21" }}
/>
);
Because each logo is an ordinary TanStack dot, it keeps what TanStack gives a dot: the tooltip (defineChart(spec, { tooltip })), pointer and keyboard focus, and keyed updates (each dot's key is its row's index, and its mark id comes
from its image, so new data moves the logos in place). The patterns are part of the SVG markup, so a server render
(renderToString of the React <Chart>) already has the logos, and serializeChartSvg / downloadChartSvg export
them with the chart. For a raster export (renderChartImage), embed the images first: a browser does not load an
external image inside an SVG drawn to a canvas, so pass embed: await embedSources(urls).
On a line chart, give logoDots only each team's last row, and each line ends in its logo. This one is dark: the
card is #111827 with a light color, the lines take teamColorScale(..., { background: "#111827" }), and the logos
are the leagues' variant: "dark" versions.
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoDots, teamColorScale, withPatterns } from "@sportsdataverse/sdvplot/tanstack";
import { defineChart, dot, lineY } from "@tanstack/charts";
import { Chart } from "@tanstack/charts/react";
import { scaleLinear } from "@tanstack/charts/scales/linear";
import { renderChartSvg } from "@tanstack/charts/svg";
await loadLeague("nfl");
const TEAMS = ["KC", "LAC", "DEN", "LV"];
const rows = TEAMS.flatMap((team) => {
const season = AFC_WEEKS_2024.find((s) => s.team === team);
if (season === undefined) throw new Error(`no 2024 weeks for ${team}`);
let total = 0;
return season.margins.map((m, i) => {
total += m ?? 0;
return { team, week: i + 1, diff: total };
});
});
// the card's background: every team colour keeps 3:1 contrast on it (the Raiders' black and the Broncos' navy
// would not), and the four stay distinct
const BG = "#111827";
const color = teamColorScale("nfl", TEAMS, { background: BG });
// the dark variants of the logos, one per team at its week-18 point
const ends = logoDots(
rows.filter((r) => r.week === 18),
{ x: "week", y: "diff", team: "team", league: "nfl", size: 28, variant: "dark" },
);
(
// TanStack Charts draws its axes and text in currentColor: the card's `color` is the foreground
<Chart
definition={defineChart({
marks: [
lineY(rows, { id: "margin", x: "week", y: "diff", z: "team", strokeWidth: 2.5 }),
...ends.map((d) => dot(d.data, d.options)),
],
scales: {
x: { scale: scaleLinear, axis: { label: "week" } },
y: { scale: scaleLinear, nice: true, grid: true, axis: { label: "point differential" } },
},
color,
})}
renderSvg={withPatterns(renderChartSvg, ends)}
idPrefix="afc-west-dark"
width={640}
height={400}
ariaLabel={`The 2024 AFC West's running point differential by week, on a dark background: ${TEAMS.map((t) => `${t} ${rows.find((r) => r.team === t && r.week === 18)?.diff}`).join(", ")} after week 18`}
style={{ background: BG, color: "#e6edf7" }}
/>
);
Why patterns, and the limits
TanStack offers three ways to put an image on a chart, and the pattern is the one that keeps the chart's own marks:
- A custom renderer or layer renderer (
createMarkwith aChartLayerRenderer) could draw<image>elements, but it has to implement mounting, server markup, focus painting and composition with the default renderer, and callingcreateMarkwould make sdvplot import TanStack at runtime. - A DOM overlay positioned from the rendered scene (
onRender) works only in the browser after a render, is missing from the server markup and the SVG export, and has to be kept in step with every update. - A pattern-filled dot is a TanStack mark like any other, and the pattern travels in the SVG through TanStack's own serializer hook.
The limits:
- SVG renderer only. The Canvas renderer (
@tanstack/charts/canvas) resolves aurl(#...)fill only to a declared gradient and leaves any other unpainted, so the logo dots draw nothing there. - Each logo is a circle as large as the logo box's diagonal: neighbouring logos' circles overlap before the logos do, and the larger circle is also the dot's hit area for focus and the tooltip.
- A chart that draws the same images twice (say a scatter and end logos of the same teams) needs
idon one of the sets: TanStack wants each mark id once.
Svelte and the DOM
Nothing is React-specific: the pieces are plain values. In Svelte, <Chart definition={...} renderSvg={withPatterns(renderChartSvg, logos)} ariaLabel="..." />
from @tanstack/charts/svelte; without a framework, mountChart(el, { definition, renderSvg, ariaLabel, idPrefix }) from
@tanstack/charts/dom. The React and Svelte <Chart> make an idPrefix of their own when you give none, but
mountChart does not: when several charts share a page, give each a unique idPrefix, or their pattern ids collide
and one chart can show another's logos.
Reading back what was drawn
drawnMarks(...sets) lists every image of the given dot sets as [id, x, y, height in px, url]: the resolved team
(or player) id, the row's own x and y values, the height and the image URL. Tests use it to check the logos
without a browser.