Skip to main content

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.

310320330340350360370380390400410420430↑ pa300350400450500pf →
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.

310320330340350360370380390400410420430↑ pa300350400450500pf →
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;
AFC, 2024
TeamWinsLosses
152
116
107
413
134
89
512
413
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.