Skip to main content

nivo

nivo draws charts as React components, and lets you add layers of your own on top of a chart's built-in ones, or swap the component that draws each node. @sportsdataverse/sdvplot/nivo gives you three pieces for it: teamColors, a chart's colors; logoLayer, a custom layer of team logos over a Bump, SwarmPlot or Chord; and logoNode, logos as the nodes themselves. It never imports nivo (install @nivo/core and the chart's package, 0.99 or later, beside it: optional peers), and it is synchronous, so load the league first (loadLeague). The ready-made bump chart is a component, <LogoBump>, in @sportsdataverse/sdvplot/react. Each chart below is drawn by nivo in your browser.

A weekly power ranking, a logo at the end of each line​

teamColors({ league }) is a colors accessor: a Bump series (and a Chord arc) is read by its id, so a series whose id is a team is drawn in the team's colour. logoLayer({ league, height }) goes into the chart's layers, after the built-in layers it should sit over. On a Bump it draws a logo at each series' last point (at: "first" or "all" for the others; a week with no rank has no point), reading the team from the series' id or, with team, from a field of the series object. The ranks here come from the AFC East's and West's 2024 results, week by week: most wins so far (a tie counts half), then the best point differential, then the name. Denver and New England share one navy, and Buffalo's is close, so the chart passes its background and every team (teams): each colour then reads and stays apart from the others (New England moves off Denver's navy to a blue of its own), as in the dark-mode section below.

import { Bump } from "@nivo/bump";
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoLayer, teamColors } from "@sportsdataverse/sdvplot/nivo";

await loadLeague("nfl");
// The rank after each week, from the week-by-week results (a margin per game; null is the bye): most wins so far (a
// tie counts half), then the best point differential, then the name. A plain ranking, not the NFL's tiebreakers.
const ranks = Array.from({ length: 18 }, (_, week) => {
const records = AFC_WEEKS_2024.map(({ team, margins }) => {
const played = margins.slice(0, week + 1).filter((m): m is number => m !== null);
const wins = played.reduce((n, m) => n + (m > 0 ? 1 : m === 0 ? 0.5 : 0), 0);
return { team, wins, diff: played.reduce((n, m) => n + m, 0) };
});
records.sort((a, b) => b.wins - a.wins || b.diff - a.diff || a.team.localeCompare(b.team));
return records.map((r) => r.team);
});
// One nivo series per team, picked by its id: its rank in each week.
const data = AFC_WEEKS_2024.map(({ team }) => ({
id: team,
data: ranks.map((order, week) => ({ x: week + 1, y: order.indexOf(team) + 1 })),
}));

(
<Bump
width={640}
height={360}
data={data}
animate={false}
margin={{ top: 24, right: 48, bottom: 40, left: 40 }}
// each series' id is a team: its line in the team's colour. Three of these eight wear navy, so pass the chart's
// background and every team: each colour then reads (3:1) and stays apart from the others
colors={teamColors({ league: "nfl", teams: AFC_WEEKS_2024.map((t) => t.team), background: "#ffffff" })}
lineWidth={3}
pointSize={6}
endLabel={false}
axisTop={null}
axisBottom={{ legend: "week", legendPosition: "middle", legendOffset: 32 }}
axisLeft={{ legend: "rank", legendPosition: "middle", legendOffset: -32 }}
// after the built-in layers: a logo at each line's last point, a tenth of the plot tall
layers={["grid", "axes", "lines", "points", logoLayer({ league: "nfl", height: 0.1 })]}
/>
);

height is a fraction of the plot height, in (0, 1], as in the spec adapters: nivo hands a custom layer the plot area's innerHeight, so a px value (24) throws. A team with no logo draws nothing and warns once, as the ECharts adapter does. The logos sit at nivo's final positions: with animate on they appear at the end of the motion rather than travel with it. The layer's group has pointer-events: none, so the lines under it keep their hover.

<LogoBump> on a dark background​

<LogoBump league data /> is nivo's Bump with both of the above: team-coloured lines and a logo at the end of each line (logoHeight, at). Every other prop goes to nivo as is. With width and height it draws at that size, without them it fills its parent (ResponsiveBump). background turns on the dark-mode palette: a team colour under 3:1 contrast against it gives way to the team's other colour, or is lightened until it reads, and a colour that would then sit too close to an earlier team's (CIE76 ΔE under 20) takes the team's other colour, or else the colour nearest its own (its lightness, hue or chroma stepped) that is distinct and still reads; if none is, it keeps the readable colour and warns once. @nivo/bump loads when the component first renders, never with sdvplot/react, and a missing package throws OptionalDependencyError then. A server render draws nothing; the chart appears once the page's scripts run. (nivo's own Bump with logoLayer renders on the server, logos included.)

123456789101112131415161718week1234rank
import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { LogoBump } from "@sportsdataverse/sdvplot/react";

await loadLeague("nfl");
// The AFC West, picked by team from the week-by-week results (a margin per game; null is the bye). The rank after
// each week: most wins so far (a tie counts half), then the best point differential, then the name.
const west = ["KC", "LAC", "DEN", "LV"].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;
});
const ranks = Array.from({ length: 18 }, (_, week) => {
const records = west.map((s) => {
const played = s.margins.slice(0, week + 1).filter((m): m is number => m !== null);
const wins = played.reduce((n, m) => n + (m > 0 ? 1 : m === 0 ? 0.5 : 0), 0);
return { team: s.team, wins, diff: played.reduce((n, m) => n + m, 0) };
});
records.sort((a, b) => b.wins - a.wins || b.diff - a.diff || a.team.localeCompare(b.team));
return records.map((r) => r.team);
});
const data = west.map((s) => ({
id: s.team,
data: ranks.map((order, week) => ({ x: week + 1, y: order.indexOf(s.team) + 1 })),
}));

const background = "#181a1b";
(
<LogoBump
league="nfl"
data={data}
// the line colours keep 3:1 against the background and stay apart from each other
background={background}
logoHeight={0.14}
width={640}
height={300}
animate={false}
margin={{ top: 24, right: 48, bottom: 40, left: 40 }}
lineWidth={4}
pointSize={0}
endLabel={false}
axisTop={null}
axisBottom={{ legend: "week", legendPosition: "middle", legendOffset: 32 }}
axisLeft={{ legend: "rank", legendPosition: "middle", legendOffset: -32 }}
theme={{ background, text: { fill: "#e3e3e3" }, grid: { line: { stroke: "#3a3d3f" } } }}
/>
);

For the same palette on nivo's own chart, pass every team the chart draws: teamColors({ league, teams, background }). The colours depend on the whole set, so background without teams throws, and a team outside teams is drawn in the fallback colour, with one warning. teamColors is the nivo counterpart of teamColorPalette in the ECharts, Recharts and Svelte adapters: an accessor nivo calls per datum, rather than a list of colours.

Logos as the nodes​

logoNode({ league, team }) is a SwarmPlot's circleComponent (or a Bump's pointComponent): each node is drawn as its team's logo, as tall as nivo sized the node (the chart's size or pointSize, in px), in place of the circle. The team is the node's id, or with team a field of its datum. It does not wire up the mouse handlers nivo hands a node, so pass useMesh for tooltips. The data is nflverse's 2024 regular season: EPA per play on offense and on defense.

-0.25-0.2-0.15-0.1-0.0500.050.10.150.20.25EPA per playoffensedefense
import { SwarmPlot } from "@nivo/swarmplot";
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoNode } from "@sportsdataverse/sdvplot/nivo";

await loadLeague("nfl");
// EPA per play = total EPA / plays, from nflverse's 2024 regular season; one node per team per side of the ball.
const nodes = NFL_TEAM_EPA_2024.flatMap((t) => [
{ id: `${t.team} offense`, team: t.team, side: "offense", epa: t.off_epa / t.off_plays },
{ id: `${t.team} defense`, team: t.team, side: "defense", epa: t.def_epa / t.def_plays },
]);

(
<SwarmPlot
width={640}
height={360}
data={nodes}
groups={["offense", "defense"]}
groupBy="side"
id="id"
value="epa"
valueFormat=">+.3f"
valueScale={{ type: "linear", min: -0.25, max: 0.25 }}
layout="horizontal"
size={22}
animate={false}
margin={{ top: 16, right: 24, bottom: 48, left: 72 }}
axisTop={null}
axisRight={null}
axisBottom={{ legend: "EPA per play", legendPosition: "middle", legendOffset: 36 }}
// logoNode draws no mouse handlers, so tooltips come from nivo's mesh
useMesh
// each node is drawn as its team's logo, as tall as the node (size); the team comes from the datum's team field
circleComponent={logoNode({ league: "nfl", team: "team" })}
/>
);

A Chord​

On a Chord, teamColors({ league }) colours each arc and its ribbons by the arc's id (the chart's keys), and logoLayer({ league, height }) in its layers puts each team's logo outside the ring at the middle of its arc, height × the plot height tall. nivo sizes the ring to the plot area, so leave room for the logos in the chart's margin.

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. x and y are nivo's pixel positions inside the plot area. A logoLayer image's height reads as the fraction of the plot height it was given; a logoNode image's reads in px, the node's size, as a node component gets no plot height.