Node and SSR
Everything in sdvplot-js runs in Node as well as the browser, which is how this site's figures are made: each one is
rendered at build time and served as static markup. Plot needs a DOM document (jsdom or linkedom), sporty's toSVG
and sdvtables' renderHTML need none, and the league data is in the package, so a server or a CI job draws charts
with no network beyond what the images themselves link to.
Observable Plot with jsdom or linkedom
Pass document to Plot.plot and read the figure's outerHTML; logos stay links the browser loads when it shows
the SVG.
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";
import { JSDOM } from "jsdom";
await loadLeague("nfl");
const { document } = new JSDOM("").window;
const svg: string = Plot.plot({
document,
marks: [logos(STANDINGS, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.1 })],
}).outerHTML;
svg;
linkedom is a lighter DOM than jsdom: enough for Plot to build the SVG, with no layout.
import * as Plot from "@observablehq/plot";
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos } from "@sportsdataverse/sdvplot/plot";
import { parseHTML } from "linkedom";
await loadLeague("nfl");
// linkedom is a lighter DOM than jsdom: enough for Plot to build the SVG, no layout.
const { document } = parseHTML("<!doctype html><html><body></body></html>");
const svg: string = Plot.plot({
document,
marks: [logos(STANDINGS, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.1 })],
}).outerHTML;
svg;
Load every league once
preloadAll() imports every league and the gsis id map, after which every …Sync function works for any league. A
server does it once at start-up; a browser page loads only the leagues it draws.
{
"aaf": 8,
"ahl": 61,
"cfb": 687,
"cricket": 111,
"echl": 59,
"mbb": 366,
"milb": 286,
"mlb": 30,
"nba": 30,
"nbagl": 32,
"ncaa_baseball": 414,
"ncaa_mhockey": 110,
"ncaa_softball": 415,
"ncaa_whockey": 48,
"nfl": 32,
"nhl": 32,
"ohl": 27,
"phf": 8,
"pwhl": 12,
"qmjhl": 30,
"soccer": 2631,
"ufl": 11,
"usfl": 9,
"ushl": 17,
"wbb": 364,
"whl": 23,
"wnba": 15,
"xfl": 11
}import { LEAGUES, preloadAll, teams } from "@sportsdataverse/sdvplot";
// Every league shard plus the gsis map, so every *Sync function works for any league afterwards.
// A server does this once at start-up; a browser page loads only the leagues it draws.
await preloadAll();
const counts: Record<string, number> = {};
for (const league of LEAGUES) counts[league] = (await teams(league)).length;
counts;
Surfaces and tables as strings
sporty's toSVG and sdvtables' renderHTML return strings with no DOM at all.
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// toSVG needs no DOM: the same string comes out in Node, a worker or the browser.
const court = surface("volleyball", "fivb");
const svg = toSVG(court, {
width: 520, // height follows the court's aspect unless given
background: "#f4efe6", // painted behind every feature
precision: 2, // decimals per coordinate (default 4); fewer is smaller
id: "fivb-court", // an id on the <svg>, for CSS or a <use href="#fivb-court">
});
svg;
| 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);
Charts written by CI
A scheduled GitHub Action can redraw a chart or table every day and commit the SVG or HTML. A script that writes the output of any example above to a file, run by a workflow like this one, is all it takes:
name: charts
on:
schedule: [{ cron: "0 12 * * *" }]
workflow_dispatch:
jobs:
draw:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install @sportsdataverse/sdvplot @sportsdataverse/sporty @sportsdataverse/sdvtables @observablehq/plot jsdom tsx
- run: npx tsx scripts/draw.ts
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add charts/
git diff --cached --quiet || git commit -m "chore: redraw charts"
git push
In the gallery: Node and SSR and sporty's SVG renderer.







