uPlot
uPlot draws time series on a canvas, fast enough for thousands of points and a
live feed. @sportsdataverse/sdvplot/uplot gives a uPlot chart team colours for its series and a plugin that paints
team logos on the canvas. It never imports uplot (install uplot 1.6.31 or later beside it, an optional peer), so the
options can be built anywhere, a server included; only new uPlot(...) needs a browser. It is synchronous: load the
league first (loadLeague). The chart below is drawn by uPlot in your browser.
Team colours and a logo at the end of each line
withTeamSeries(opts, rows, { team, league }) colours uPlot's series from rows of teams: row i's team strokes
series[i + 1] (series[0] is uPlot's x). The rest of each series (label, width, value formatter) is kept, and the
options are copied, never changed. fill: 0.2 also fills the area under each line in its colour at that opacity. A
team that does not resolve takes fallback (default #808080).
logoPlugin(rows, { x, y, team, league, height }) is a uPlot plugin: put it in plugins. Its draw hook paints each
row's logo centred on the row's x and y (in data units, placed with uPlot's own valToPos), height of the plot
area tall (a fraction in (0, 1], as in the Plotly, Vega-Lite and ECharts adapters; default 0.1). Here the rows are the
last point of each line, so each line ends in its team's logo.
With JavaScript on, uPlot draws this figure here.
{
"series": [
{
"label": "Minute"
},
{
"label": "Philadelphia",
"width": 2,
"stroke": "#004c54"
},
{
"label": "Kansas City",
"width": 2,
"stroke": "#e31837"
}
],
"ends": [
{
"minute": 60,
"wp": 1,
"team": "PHI"
},
{
"minute": 60,
"wp": 0,
"team": "KC"
}
]
}import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoPlugin, withTeamSeries } from "@sportsdataverse/sdvplot/uplot";
import type uPlot from "uplot";
await loadLeague("nfl");
// uPlot's data is columns: x first, then one array per series. Philadelphia (home) and Kansas City, by minute.
const minutes = SUPER_BOWL_LIX_WP.map((p) => p.minute);
const phi = SUPER_BOWL_LIX_WP.map((p) => p.home_wp);
// a two-team game: Kansas City's win probability is 1 - home_wp, derived from the home series, not a column of its own
const kc = phi.map((wp) => 1 - wp);
const data: uPlot.AlignedData = [minutes, phi, kc];
// each team's logo at its line's last point, picked by team, not by position
const last = minutes.length - 1;
const ends = [
{ minute: minutes[last], wp: phi[last], team: "PHI" },
{ minute: minutes[last], wp: kc[last], team: "KC" },
];
const pct = (v: number | null): string => (v === null ? "" : `${Math.round(v * 100)}%`);
// Built fresh per chart, as the docs do: uPlot keeps its own copy of what it is given.
const opts = (): uPlot.Options =>
withTeamSeries<uPlot.Options>(
{
width: 640,
height: 320,
// room past 60 minutes and beyond 0% / 100% for the end logos
scales: { x: { time: false, range: [0, 63] }, y: { range: [-0.1, 1.1] } },
axes: [
{ label: "Minutes played", splits: () => [0, 15, 30, 45, 60] },
{ values: (_u, splits) => splits.map(pct), splits: () => [0, 0.25, 0.5, 0.75, 1] },
],
series: [
{ label: "Minute" },
{ label: "Philadelphia", width: 2, value: (_u, v) => pct(v) },
{ label: "Kansas City", width: 2, value: (_u, v) => pct(v) },
],
plugins: [logoPlugin(ends, { x: "minute", y: "wp", team: "team", league: "nfl", height: 0.16 })],
},
// row i colours series i + 1, so the lines and their logos share one team lookup
[{ team: "PHI" }, { team: "KC" }],
{ team: "team", league: "nfl", background: "#ffffff" },
);
// In the browser: new uPlot(opts(), data, element), as this page does.
export const browser = {
lib: "uplot",
opts,
data,
width: 640,
height: 320,
label:
"Super Bowl LIX win probability by minute, Philadelphia in Eagles green and Kansas City in Chiefs red, each line ending in its team's logo",
} as const;
// uPlot draws only in a browser; without JavaScript the page shows the series it is given.
{ series: opts().series, ends };
Building the plugin fetches nothing. The images start loading when the chart starts (uPlot's init hook, before its
first draw), with crossOrigin = "anonymous" so the canvas stays exportable, and the chart redraws as each one lands. A team with no
logo draws nothing and warns once, as the ECharts adapter does; an image that fails to load is never drawn (no broken
image) and warns once. A point outside the plot area, after a zoom, is not drawn over the axes. y is read on the "y"
scale; pass scale for another one. x is on uPlot's "x" scale, and a time scale counts seconds.
Dark mode
uPlot draws on a transparent canvas, so the series colours have to read on whatever is behind it. Pass the chart's
background as background and every colour keeps at least 3:1 contrast against it (WCAG's minimum for graphics): a
team colour under that gives way to the team's other colour, else is lightened (on a dark background) or darkened
until it reads. Each colour must also stay apart from every earlier team's (a CIE76 ΔE of 20 or more): one that
would land too close 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, so two teams never share one colour (on a dark chart the Lakers and the Warriors would otherwise both turn
gold). If nothing works it keeps the readable colour and warns once. A colour that already reads and is distinct is
kept. Without background the colours are the teams' own.
[
{
"team": "Clippers",
"light": "#12173f",
"dark": "#656882",
"darkContrast": 3.2
},
{
"team": "Suns",
"light": "#29127a",
"dark": "#e56020",
"darkContrast": 5
},
{
"team": "Lakers",
"light": "#be8b1d",
"dark": "#fdb927",
"darkContrast": 10.1
},
{
"team": "Kings",
"light": "#6a7a82",
"dark": "#9056a9",
"darkContrast": 3.4
},
{
"team": "Warriors",
"light": "#1d428a",
"dark": "#ff9600",
"darkContrast": 8
}
]import { NBA_STANDINGS } from "@sportsdataverse/examples/data";
import { contrast, loadLeague } from "@sportsdataverse/sdvplot";
import { withTeamSeries } from "@sportsdataverse/sdvplot/uplot";
await loadLeague("nba");
type Series = { label?: string; stroke?: string };
// one series per team (series[0] is uPlot's x), coloured from one row per team
const series: Series[] = [{ label: "Game" }, ...NBA_STANDINGS.map((s) => ({ label: `${s.city} ${s.team}` }))];
const rows = NBA_STANDINGS.map((s) => ({ team: s.team }));
const strokes = (background: string): string[] =>
withTeamSeries({ series }, rows, { team: "team", league: "nba", background })
.series.slice(1)
.map((s) => s.stroke ?? "");
// uPlot draws on a transparent canvas: give withTeamSeries the colour behind it, and a team colour that would not
// read there gives way to the team's other colour, or is lightened (dark) or darkened (light) until it does; one that
// would land too close to an earlier team's is moved apart (on the dark chart the Lakers and Warriors keep two colours).
const light = strokes("#ffffff");
const dark = strokes("#181a1b");
NBA_STANDINGS.map((s, i) => ({
team: s.team,
light: light[i],
dark: dark[i],
darkContrast: Math.round(contrast(dark[i] ?? "", "#181a1b") * 10) / 10,
}));
uPlot's own axes and legend are dark ink by default; on a dark page set each axis' stroke and the legend's colour too.
A live feed
There is no withLiveAppend: appending is uPlot's own u.setData(data). The team colours live in the options, so
they survive every setData. For logos that follow the data, pass logoPlugin a function of the chart instead of
rows: it is called at every draw, so it can read the newest point from u.data.
This replays Super Bowl LIX one play at a time, as a live feed would arrive (a browser module; replay(el) returns
what stops it):
// live-replay.ts: Super Bowl LIX replayed one play at a time, as a live feed would arrive. Browser only; peer: uplot.
import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type UPlotLike, logoPlugin, withTeamSeries } from "@sportsdataverse/sdvplot/uplot";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
/** Draws the replay into `el`, one play every `ms`; returns what stops it. */
export async function replay(el: HTMLElement, ms = 100): Promise<() => void> {
await loadLeague("nfl");
// the columns the feed appends to: minutes played, then each team's win probability
const minutes: number[] = [];
const phi: number[] = [];
const kc: number[] = [];
// a logo at each line's newest point: a function of the chart, so the plugin reads it again at every draw
const ends = (u: UPlotLike) => {
const [xs, home, away] = u.data;
const i = (xs?.length ?? 0) - 1;
if (i < 0) return [];
return [
{ minute: xs?.[i], wp: home?.[i], team: "PHI" },
{ minute: xs?.[i], wp: away?.[i], team: "KC" },
];
};
const opts = withTeamSeries<uPlot.Options>(
{
width: 640,
height: 320,
scales: { x: { time: false, range: [0, 63] }, y: { range: [-0.1, 1.1] } },
series: [{ label: "Minute" }, { label: "Philadelphia", width: 2 }, { label: "Kansas City", width: 2 }],
plugins: [logoPlugin(ends, { x: "minute", y: "wp", team: "team", league: "nfl", height: 0.16 })],
},
[{ team: "PHI" }, { team: "KC" }],
{ team: "team", league: "nfl", background: "#ffffff" },
);
const u = new uPlot(opts, [minutes, phi, kc], el);
let next = 0;
const timer = setInterval(() => {
const play = SUPER_BOWL_LIX_WP[next++];
if (play === undefined) return clearInterval(timer);
minutes.push(play.minute);
phi.push(play.home_wp);
kc.push(1 - play.home_wp);
// the append is uPlot's own: the team colours live in the options, and the logos move with the data
u.setData([minutes, phi, kc]);
}, ms);
return () => {
clearInterval(timer);
u.destroy();
};
}
Each team's logo loads once, however often the chart redraws.
Reading back what was drawn
drawnMarks(u) returns [team id, x, y, height, url] for each logo the chart's plugins last painted, height as a
fraction of the plot area, the same shape as the spec adapters' drawnMarks. A test can draw the chart (a browser, or
jsdom with a 2D context such as @napi-rs/canvas behind its canvases) and check which teams were drawn where.
uPlot's release cadence
uPlot's last release, 1.6.32, came out on 2025-03-14. If no release follows, uPlot leaves the two-year
release window in March 2027, and its place here is reviewed then. The adapter needs little of it: it reads a chart's
ctx, bbox, data, valToPos and redraw, and writes series options.