MUI X Charts
MUI X Charts (@mui/x-charts, the MIT community package) draws React charts as SVG,
styled by Material UI's theme. @sportsdataverse/sdvplot/mui gives them team colours and puts team logos and player
headshots where MUI draws its own marks, through the slots MUI provides for that: a scatter's marker, a line's mark
and an axis' axisTickLabel. The subpath never imports MUI: its slots are plain function components that return an
SVG <image>, and its types only match MUI's shapes (checked against MUI's own types). @mui/x-charts is an optional
peer, 9.15.0 or later; these examples run 9.15.0 with @mui/material 9.5.0. MUI's own peers (@mui/material,
@mui/system, @emotion/react and @emotion/styled) are your app's, as for any MUI chart. Like every sdvplot logo
helper, the slots are synchronous, so load the league first (loadLeague). Each chart below is drawn by MUI in your
browser.
Logos as scatter points
logoMarker({ league, teams }) is a scatter's marker slot: <ScatterChart slots={{ marker }}>. MUI hands the slot
each point's series id, its index in the series' data and its position, so teams[i] is the team of point i. Pass the
teams in the same order as the points; for several series, pass a list per series id, teams: { west: [...], east: [...] }. Each logo is centred on its point and twice the series' markerSize tall, in px, so set markerSize on the
series (12 gives 24 px logos). MUI's tooltip, its highlight (the logo grows 1.2 times, as MUI's own marker does) and its
fade (0.3 opacity while another series is highlighted) keep working, and alpha sets the logos' opacity. A slot is a
component, so build it once, at module scope or with useMemo: a new one on every render remounts the points.
The chart below has all 32 NFL teams' 2024 offence and defence EPA per play, each point its team's logo. The y axis is reversed, so the best defences are at the top.
import { ScatterChart } from "@mui/x-charts/ScatterChart";
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoMarker } from "@sportsdataverse/sdvplot/mui";
await loadLeague("nfl");
// all 32 teams, in the data's order: point i is NFL_TEAM_EPA_2024[i], so its team is teams[i]
if (NFL_TEAM_EPA_2024.length !== 32) throw new Error(`${NFL_TEAM_EPA_2024.length} teams, not 32`);
const teams = NFL_TEAM_EPA_2024.map((t) => t.team);
const points = NFL_TEAM_EPA_2024.map((t) => ({
x: t.off_epa / t.off_plays,
y: t.def_epa / t.def_plays,
}));
// built once, at module scope: a new slot on every render would remount the points
const marker = logoMarker({ league: "nfl", teams });
const perPlay = (v: number | null) => (v === null ? "" : v.toFixed(3));
(
<ScatterChart
width={640}
height={460}
skipAnimation
margin={{ top: 16, right: 24 }}
xAxis={[{ label: "offence: EPA per play", valueFormatter: perPlay }]}
// a defence wants negative EPA: the best defences sit at the top
yAxis={[{ label: "defence: EPA per play allowed", reverse: true, valueFormatter: perPlay, width: 72 }]}
// markerSize is the logo's half-height in px: 24 px logos
series={[
{
data: points,
markerSize: 12,
valueFormatter: (p, { dataIndex }) =>
`${teams[dataIndex]}: offence ${perPlay(p.x)}, defence ${perPlay(p.y)}`,
},
]}
slots={{ marker }}
/>
);
headshotMarker({ league, players }) does the same with ESPN player headshots: players[i] is the ESPN athlete id of
point i (or an nflverse gsis id with idSystem: "gsis", after loadGsis()).
Logos at the end of each line
logoMark({ league }) is a line's mark slot: <LineChart slots={{ mark }}>. Each mark is drawn as its series' team
logo, size px tall (24 by default) and centred where MUI puts the mark. The series' id is its team, or give teams,
a map from series id to team. Which points get a mark is the series' own showMark: a function such as ({ index }) => index === 17 keeps one mark, so one logo, at the end of each line. A logo at the right edge of the plot overflows it by
half its width; leave room with the chart's margin.
Team colours, light and dark
teamColors(league, teams) is a chart's colors, in the form MUI calls with the theme's mode: under a dark
ThemeProvider each series takes its team colour for MUI's dark background (#121212), and under a light one its colour
for white. Series i takes teams[i]'s colour, so give the series in the same order. Every colour keeps 3:1 contrast
against its background: a team colour that falls short, such as Denver's navy or the Raiders' black on #121212, gives
way to the team's other colour, or is lightened until it reads. Each colour also aims to stay distinct from the earlier
teams' (CIE76 ΔE 20), as in the other adapters. For other backgrounds, pass them as hex: background: { light, dark }.
which, season and idSystem work as they do in teamColorPalette, which returns the colours for one background as
a plain list (for a series' color, say).
This chart sits in a dark ThemeProvider (createTheme({ palette: { mode: "dark" } })): MUI draws its axes and text
for the dark theme, teamColors gives the lines their dark-mode colours, and logoMark ends each line in its logo.
- KC
- LAC
- DEN
- LV
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { LineChart } from "@mui/x-charts/LineChart";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoMark, teamColors } from "@sportsdataverse/sdvplot/mui";
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 = ["KC", "LAC", "DEN", "LV"];
const weeks = Array.from({ length: 18 }, (_, i) => i + 1);
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 data: number[] = [];
for (const m of season.margins) data.push((data.at(-1) ?? 0) + (m ?? 0));
// the series id is the team: logoMark draws its logo at the one mark showMark keeps, week 18
return { id: team, label: team, data, showMark: ({ index }: { index: number }) => index === 17 };
});
const dark = createTheme({ palette: { mode: "dark" } });
(
<ThemeProvider theme={dark}>
{/* MUI's dark background; teamColors gives each line its colour for the theme's mode */}
<div style={{ background: dark.palette.background.default, width: 640 }}>
<LineChart
width={640}
height={380}
skipAnimation
margin={{ top: 16, right: 32 }}
colors={teamColors("nfl", teams)}
xAxis={[{ data: weeks, label: "week", tickInterval: [1, 6, 12, 18] }]}
yAxis={[{ label: "point differential", width: 56 }]}
series={series}
slots={{ mark: logoMark({ league: "nfl", size: 28 }) }}
/>
</div>
</ThemeProvider>
);
teamColorGetter(league, teams) is a series' colorGetter, a colour per data point: bar or point i takes teams[i]'s
colour. A team that repeats keeps one colour. It takes background too, but MUI hands a colorGetter no theme mode, so
pass the background yourself.
Logos on an axis
logoTickLabel({ league }) is an axis' axisTickLabel slot, for a band axis whose values are teams. Each logo sits
where MUI would put the label: centred under its band on a bottom axis, left of it on a left axis. It is size px tall
(24 by default), so give the axis room: an x axis' height or a y axis' width. The slot reads the label MUI hands it,
the axis' formatted value, so a valueFormatter must keep the label a team (an abbreviation, a name or an id). A label
that resolves to no logo keeps its text, and warns once.
Give the slot to the team axis alone, through MUI's composition API: <ChartsContainer> with <BarPlot />, and
<ChartsXAxis axisId="teams" slots={{ axisTickLabel }} /> for the team axis. A chart's own slots reach every axis,
and a value axis' label such as "12" would then resolve as team id 12. In @mui/x-charts 9.15, BarChart and the other
one-component charts also drop the slots you give an axis inside its xAxis entry, so the logos never show there
(sdvplot's tests pin this, so a fixed MUI shows up). The composition API leaves out the keyboard focus mark that
BarChart draws; add <FocusedBar /> back, as this chart does.
import { BarPlot, FocusedBar } from "@mui/x-charts/BarChart";
import { ChartsContainer } from "@mui/x-charts/ChartsContainer";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { ChartsTooltip } from "@mui/x-charts/ChartsTooltip";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoTickLabel, teamColorGetter } from "@sportsdataverse/sdvplot/mui";
await loadLeague("nfl");
// each team picked by id from the week-by-week score margins (null is the bye week); a win is a positive margin
const teams = ["KC", "BUF", "LAC", "DEN", "MIA", "LV", "NYJ", "NE"];
const wins = 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.filter((m) => m !== null && m > 0).length;
});
// bar i takes teams[i]'s colour; the logos go on the team axis alone, as a prop of its <ChartsXAxis>
const tick = logoTickLabel({ league: "nfl", size: 30 });
(
<ChartsContainer
width={640}
height={360}
skipAnimation
xAxis={[{ id: "teams", scaleType: "band", data: teams, height: 44 }]}
yAxis={[{ id: "wins", label: "wins", min: 0, max: 17 }]}
series={[{ type: "bar", label: "wins", data: wins, colorGetter: teamColorGetter("nfl", teams) }]}
>
<ChartsGrid horizontal />
<BarPlot />
{/* the composition API leaves out the keyboard focus mark the one-component BarChart draws: add it back */}
<FocusedBar />
<ChartsXAxis axisId="teams" slots={{ axisTickLabel: tick }} />
<ChartsYAxis axisId="wins" />
<ChartsTooltip />
</ChartsContainer>
);
Why slots
MUI also exposes its scales to a chart's children (useXScale, useYScale, useDrawingArea), and a child component
could draw logos with them. Slots are simpler here: MUI hands each one the position it has already worked out, so the
logo lands exactly on MUI's own mark, keeps its tooltip, highlight and fade, and renders on the server with the rest of
the chart. A child that reads the hooks would have to load MUI from sdvplot, and would draw nothing on the server.
Missing logos, testing and server rendering
A team or player with no image in the archive draws nothing where its mark would be, and warns once, as in every sdvplot
adapter. Each image carries the data-sdv-* attributes that drawnMarks(container) from @sportsdataverse/sdvplot/testing
reads back, so a test can check which logos a chart drew, where, and how tall.
MUI's charts are client components. The slots are functions, so they cannot be passed from a server component to a
client one: build them in the client component, once the league has loaded. A client component still renders once on
the server, and with a fixed width and height MUI draws the chart there, logos included, as plain <image> tags.
MUI's keyboard navigation stays on: tab into a chart and press an arrow key, and MUI draws its focus mark round the focused point or bar.
In the gallery: MUI X Charts.