Using sdvplot with Game on Paper (Chart.js + Astro/Svelte)
Game on Paper renders its pages in Astro and draws its charts with Chart.js inside Svelte 5
islands. sdvplot fits that stack without a wrapper: its Chart.js subpath returns plain dataset options and plugins, and
matchupColors is Game on Paper's own "game colours" picker, so a page can choose a matchup's colours on the server
and hand them to the island that draws. This guide follows one game page from the Astro frontmatter to the browser.
Colours for a game, chosen on the server
In the page's frontmatter, await loadLeague("cfb") and call matchupColorsSync(home, away, { league }); pass the
result to the island as a prop. It holds a light and a dark pair, home first, and the island picks one with
window.matchMedia("(prefers-color-scheme: dark)") when it draws.
{
"light": [
"#9e1b32",
"#2c2a29"
],
"dark": [
"#ffffff",
"#ba0c2f"
]
}import { loadLeague, matchupColorsSync } from "@sportsdataverse/sdvplot";
await loadLeague("cfb");
matchupColorsSync("Alabama", "Georgia", { league: "cfb" });
The page, the island and the chart
The Astro component picks the colours in its frontmatter and renders the chart as a client:only="svelte" island, so
none of the chart code runs during server rendering.
---
// src/components/game/GameWinProbability.astro: runs on the server, under the Cloudflare adapter as under Node
import { loadLeague, matchupColorsSync } from "@sportsdataverse/sdvplot";
import WinProbabilityChart from "./WinProbabilityChart.svelte";
interface Props {
/** The two teams as the game page names them, e.g. "Georgia" and "Alabama". */
home: string;
away: string;
/** The home team's win probability after each play, against minutes played. */
plays: { minute: number; home_wp: number }[];
}
const { home, away, plays }: Props = Astro.props;
await loadLeague("cfb");
// Game on Paper's "game colours": a light and a dark pair, home first, chosen here so no picker code ships
const colors = matchupColorsSync(home, away, { league: "cfb" });
---
<WinProbabilityChart client:only="svelte" home={home} away={away} plays={plays} colors={colors} />
The island builds the chart in an $effect and destroys it in the effect's cleanup. It calls loadLeague again
first: the frontmatter's call loaded the league on the server, and the browser bundle has its own copy to fill. It
reads every prop before that call, because Svelte tracks only what an effect reads synchronously: a prop read inside
the .then would not re-run the effect when it changes.
<script lang="ts">
// src/components/game/WinProbabilityChart.svelte: a client:only island, so this runs in the browser only
import { type MatchupColors, loadLeague } from "@sportsdataverse/sdvplot";
import { logoWatermarks } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, registerables } from "chart.js";
interface Props {
home: string;
away: string;
plays: { minute: number; home_wp: number }[];
colors: MatchupColors;
}
const { home, away, plays, colors }: Props = $props();
let canvas: HTMLCanvasElement | undefined = $state(); // bound to the <canvas> below
Chart.register(...registerables);
$effect(() => {
// Svelte tracks only what an effect reads synchronously; a read inside the .then below is not tracked. So every
// prop, and the bound canvas, is read here first, and a change to any of them re-runs the effect. Plain arrays,
// so Chart.js never walks a reactive proxy.
const target = canvas;
const homeName = home;
const awayName = away;
const [homeColor, awayColor] = matchMedia("(prefers-color-scheme: dark)").matches ? colors.dark : colors.light;
const homeLine = plays.map((p) => ({ x: p.minute, y: p.home_wp }));
const awayLine = plays.map((p) => ({ x: p.minute, y: 1 - p.home_wp }));
let chart: Chart | undefined;
let live = true;
// The frontmatter's loadLeague ran on the server; the browser bundle loads the league again before
// logoWatermarks (and teamColor, if used) look teams up.
loadLeague("cfb").then(() => {
if (!live || target === undefined) return;
chart = new Chart(target, {
type: "line",
data: {
datasets: [
{ label: homeName, data: homeLine, borderColor: homeColor, pointRadius: 0 },
{ label: awayName, data: awayLine, borderColor: awayColor, pointRadius: 0 },
],
},
options: { scales: { x: { type: "linear", min: 0, max: 60 }, y: { min: 0, max: 1 } } },
plugins: [logoWatermarks([homeName, awayName], { league: "cfb" })],
});
});
return () => {
live = false;
chart?.destroy();
};
});
</script>
<canvas bind:this={canvas}></canvas>
Chart.js cannot draw under the jsdom these docs run examples in, so these two files are typechecked rather than run; the PNGs on this page are the same Chart.js calls drawn in Node.
A chart in those colours
The pair goes on each dataset's borderColor, and logoWatermarks([home, away]) puts faint team logos behind the
lines, home top-left and away bottom-left, as on Game on Paper's win-probability chart.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { matchupColors } from "@sportsdataverse/sdvplot";
import { type WatermarkOptions, logoWatermarks } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";
// Two colours that read apart and on a light page: Philadelphia (home) first, as on Game on Paper.
const { light } = await matchupColors("PHI", "KC", { league: "nfl" });
const [phi, kc] = light;
const line = (label: string, color: string, wp: (p: number) => number) => ({
label,
data: SUPER_BOWL_LIX_WP.map((p) => ({ x: p.minute, y: wp(p.home_wp) })),
borderColor: color,
backgroundColor: color,
borderWidth: 2,
pointRadius: 0,
});
// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given). A
// browser loads each logo itself; Node passes `loadImage` (below).
const config = (o: Pick<WatermarkOptions, "loadImage"> = {}): ChartConfiguration<"line"> => ({
type: "line",
data: {
datasets: [line("Philadelphia", phi, (wp) => wp), line("Kansas City", kc, (wp) => 1 - wp)],
},
options: {
responsive: false,
animation: false,
scales: {
x: {
type: "linear",
min: 0,
max: 60,
ticks: { stepSize: 15 },
title: { display: true, text: "Minutes played" },
},
y: { min: 0, max: 1, ticks: { callback: (v) => `${Math.round(Number(v) * 100)}%` } },
},
},
// Game on Paper's watermarks: the home team top-left, the away team bottom-left, 40% opaque behind the lines
plugins: [logoWatermarks(["PHI", "KC"], { league: "nfl", size: 60, ...o })],
});
// In the browser: new Chart(canvas, config()) on a 640 x 320 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 320,
label:
"Super Bowl LIX win probability, Philadelphia and Kansas City, by minute, with faint Eagles and Chiefs logos behind the lines",
} as const;
Chart.register(...registerables);
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
Chart.defaults.font.family = "Source Sans 3";
// In Node the image plugins load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas (whose
// own loadImage(url) also fetches by itself). Keep the promises: once they settle, the plugins have redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
const canvas = createCanvas(browser.width, browser.height);
// Chart.js types want a DOM canvas; @napi-rs/canvas has the same 2D context. Outside a DOM, Chart.js draws
// synchronously on its BasicPlatform (no resize, no events).
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new Chart(ctx, config({ loadImage: load }));
await Promise.all(loads);
const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="320" alt="${browser.label}">`;
Datasets in team colours
teamColor(teams, league) gives one colour per team for backgroundColor or borderColor, and teamFill the
translucent fill under a radar or area dataset. Both look teams up synchronously, so in the island call them after
loadLeague resolves, as the chart above does.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColor, teamFill } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";
await loadLeague("nfl");
const teams = STANDINGS.map((s) => s.team);
// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given).
const config = (): ChartConfiguration<"bar"> => ({
type: "bar",
data: {
labels: teams,
datasets: [
// one colour per bar: teamColor for the solid points-for bars, teamFill (0.2 opacity) under the outlined ones
{ label: "Points for", data: STANDINGS.map((s) => s.pf), backgroundColor: teamColor(teams, "nfl") },
{
label: "Points against",
data: STANDINGS.map((s) => s.pa),
backgroundColor: teamFill(teams, "nfl"),
borderColor: teamColor(teams, "nfl"),
borderWidth: 2,
},
],
},
options: { responsive: false, animation: false, plugins: { legend: { display: false } } },
});
// In the browser: new Chart(canvas, config()) on a 640 x 320 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 320,
label: "2024 AFC points for (solid) and against (outlined) by team",
} as const;
Chart.register(...registerables);
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
Chart.defaults.font.family = "Source Sans 3";
const canvas = createCanvas(browser.width, browser.height);
new Chart(canvas.getContext("2d") as unknown as CanvasRenderingContext2D, config());
const png = canvas.toBuffer("image/png").toString("base64");
`<img src="data:image/png;base64,${png}" width="640" height="320" alt="${browser.label}">`;
Logos on points
logoPoints(teams, { league }) turns each point into the team's logo, and the pointImages plugin draws each one once
its image loads. Like the watermarks, build them inside the island after loadLeague resolves: in the browser they
create <img> elements, and without a loadImage option (below) they throw UnsupportedTargetError during server
rendering.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, type Image, createCanvas, loadImage } from "@napi-rs/canvas";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type PointOptions, logoPoints, pointImages } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";
await loadLeague("nfl");
const west = STANDINGS.filter((s) => s.division === "West");
// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given). A
// browser loads each logo itself; Node passes `loadImage` (below).
const config = (o: Pick<PointOptions, "loadImage"> = {}): ChartConfiguration<"scatter"> => ({
type: "scatter",
data: {
datasets: [
{
data: west.map((s) => ({ x: s.pf, y: s.pa })),
// one logo per point, in data order; each slot draws nothing until its image lands
...logoPoints(
west.map((s) => s.team),
{ league: "nfl", radius: 20, ...o },
),
},
],
},
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { min: 280, max: 440, title: { display: true, text: "Points for" } },
// fewer points against is better: reversed, so the best teams sit top right
y: { min: 280, max: 440, reverse: true, title: { display: true, text: "Points against" } },
},
},
// pointImages updates the chart as each point's image lands
plugins: [pointImages],
});
// In the browser: new Chart(canvas, config()) on a 640 x 400 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 400,
label:
"The 2024 AFC West as team logos, points for against points against: Kansas City, the Chargers, Denver and Las Vegas",
} as const;
// In Node the image helpers load marks through `loadImage`: fetch each URL and decode it on @napi-rs/canvas (whose
// own loadImage(url) also fetches by itself). Keep the promises: once they settle, pointImages has redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => loadImage(Buffer.from(b)));
loads.push(p);
return p;
};
Chart.register(...registerables);
// A build server may have no system fonts (Vercel's has none): register a bundled one before drawing, or every
// label is blank. Source Sans 3, SIL Open Font License (examples/fonts/OFL.txt).
const font = join(dirname(fileURLToPath(import.meta.url)), "../../../fonts/SourceSans3-Regular.ttf");
if (!GlobalFonts.registerFromPath(font, "Source Sans 3")) throw new Error(`no font at ${font}`);
Chart.defaults.font.family = "Source Sans 3";
const canvas = createCanvas(browser.width, browser.height);
const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
const chart = new Chart(ctx, config({ loadImage: load }));
await Promise.all(loads);
const png = canvas.toBuffer("image/png").toString("base64");
chart.destroy();
`<img src="data:image/png;base64,${png}" width="640" height="400" alt="${browser.label}">`;
A court or field behind the data
surface(league, { team }) from @sportsdataverse/sdvplot/chartjs/surface is a Chart.js plugin that paints the
playing surface under the datasets, with scales that keep its proportions. With watermarks on the same chart, list
court.plugin before logoWatermarks in plugins: Chart.js runs plugins in array order, so a court listed second
paints over the logos (sdvplot warns chartjs:surface:order when it sees that order).
import { createCanvas } from "@napi-rs/canvas";
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColor } from "@sportsdataverse/sdvplot/chartjs";
import { surface } from "@sportsdataverse/sdvplot/chartjs/surface";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
import { Chart, type ChartConfiguration, registerables } from "chart.js";
await loadLeague("nba");
// The Plot shot chart's 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" });
const court = surface("nba", { team: "DEN", displayRange: "defense" });
// made shots filled in the shooting team's colour, misses white with a ring in it: both read on the wood and the paint
const color = teamColor(
shots.map((s) => s.team),
"nba",
);
// One config for the browser and for Node, built fresh per chart (Chart.js keeps state on what it is given).
const config = (): ChartConfiguration<"scatter"> => ({
type: "scatter",
data: {
datasets: [
{
data: shots.map((s) => ({ x: Number(s.surface_x), y: Number(s.surface_y) })),
backgroundColor: color.map((c, i) => (shots[i]?.made ? c : "#ffffff")),
borderColor: color.map((c, i) => (shots[i]?.made ? "#ffffff" : c)),
borderWidth: 2,
pointRadius: 7,
},
],
},
// court.plugin paints the court under the datasets; court.scales keeps one foot the same length on both axes
plugins: [court.plugin],
options: {
responsive: false,
animation: false,
layout: { padding: 0 },
plugins: { legend: { display: false } },
scales: { x: { ...court.scales.x, display: false }, y: { ...court.scales.y, display: false } },
},
});
const [x0, y0, x1, y1] = court.scene.bbox;
const width = 640;
// In the browser: new Chart(canvas, config()) on a canvas of the court's aspect, as this page does.
export const browser = {
lib: "chartjs",
config,
width,
height: Math.round((width * (y1 - y0)) / (x1 - x0)),
label:
"The 38 fourth-quarter shots of the Lakers at the Nuggets, 24 October 2023, on the defensive half of a Nuggets-painted court: made shots filled in the team colour, misses white",
} as const;
Chart.register(...registerables);
const canvas = createCanvas(browser.width, browser.height);
new Chart(canvas.getContext("2d") as unknown as CanvasRenderingContext2D, config());
const png = canvas.toBuffer("image/png").toString("base64");
`<img src="data:image/png;base64,${png}" width="${canvas.width}" height="${canvas.height}" alt="${browser.label}">`;
Images rendered on the server
The PNGs on this page come from Chart.js on @napi-rs/canvas, with each image helper given loadImage so it loads
marks without a DOM. That route needs a Node runtime: @napi-rs/canvas is a native module, so it runs under Astro's
Node adapter but not under the Cloudflare adapter Game on Paper deploys with. On Cloudflare, draw charts in the
browser as above; keep loadImage for code that runs where a Node canvas exists, such as a build step or a separate
Node service for social cards. Register a font with GlobalFonts before drawing, because a server image may have no
fonts and every label would come out blank.
Tables
sdvtables renders a table to an HTML string in the frontmatter (await renderHTMLAsync(spec, rows)), which the page
inserts with Astro's set:html. No JavaScript reaches the browser for it.
| Team | Wins | Losses |
|---|---|---|
![]() | 15 | 2 |
![]() | 11 | 6 |
![]() | 10 | 7 |
![]() | 4 | 13 |
![]() | 13 | 4 |
![]() | 8 | 9 |
![]() | 5 | 12 |
![]() | 4 | 13 |
import { STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
export const spec = defineTable<(typeof STANDINGS)[number]>()
.columns((c) => [c.logo("team", { league: "nfl" }), c.int("wins"), c.int("losses")])
.title("AFC, 2024")
.build();
await prepare(spec); // loads the NFL shard renderHTML needs; renderHTML itself is synchronous
renderHTML(spec, STANDINGS);
In the gallery: Chart.js and sdvtables' HTML renderer.







