Skip to main content

Plotly, Vega-Lite, ECharts and Chart.js

Not every project draws with Plot or D3. The Plotly, Vega-Lite and ECharts subpaths patch a plain spec object (a figure, a spec, an option) and never import the charting library, so they work with whatever build you already load; the Chart.js subpath returns dataset options and plugins. All of them are synchronous, so load the league first (loadLeague). Each chart below, except the three static embedSources outputs, is drawn by its own library in your browser, from the spec the example exports as browser; hover it, zoom it or save it as that library allows. The code also renders the Vega-Lite, ECharts and Chart.js charts in Node, where each library runs without a browser: that render is what the page shows before (or without) JavaScript.

Plotly​

withLogos(figure, rows, { league, x, y, team }) returns a new figure with one layout.images entry per row, for Plotly.newPlot(div, figure.data, figure.layout). plotly.js loads each image itself, so without JavaScript these examples show the figure rather than a render.

With JavaScript on, plotly.js draws this figure here.

{
  "data": [
    {
      "type": "scatter",
      "mode": "markers",
      "x": [
        385,
        402,
        425,
        309,
        525,
        345,
        338,
        289
      ],
      "y": [
        326,
        301,
        311,
        434,
        368,
        364,
        404,
        417
      ]
    }
  ],
  "layout": {
    "width": 560,
    "height": 400,
    "xaxis": {
      "range": [
        260,
        550
      ]
    },
    "yaxis": {
      "range": [
        280,
        460
      ]
    },
    "images": [
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/3d/3d77958dc6373768919bb2681cbe1b143f56c07a1f013460def665a5026a7f3d.png",
        "x": 385,
        "y": 326,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:12"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/54/5400f85bd93129c056717a771da57a97225e15f39c0022107ce89ef993f15bb0.png",
        "x": 402,
        "y": 301,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:24"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/c9/c98bec2be32e27b19f79f5da86ac6ef133c78d75ab78aadb28ef36696c3213e8.png",
        "x": 425,
        "y": 311,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:7"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/25/25fbb03e972ae872fa024026b73c7b63ef9f23c2f2c51f87d1614d800dcee6e7.png",
        "x": 309,
        "y": 434,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:13"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/79/79b71e2f536ee29f9d23834e89828883af2d95bf6968cbd07a505444229cdd20.png",
        "x": 525,
        "y": 368,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:2"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/b9/b9631269a82abda39bd748afc82390679ca67bfc167bc3581665e237407de8e0.png",
        "x": 345,
        "y": 364,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:15"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/5d/5de8d029f3602c97ffe02bd636eb2630e55d019d1a37cf819d3b9e39a219487b.png",
        "x": 338,
        "y": 404,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:20"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/da/dac19aa9a573dcf9075e2422bee3e35a4955b9ab494bb3191ab3bacfd3609729.png",
        "x": 289,
        "y": 417,
        "xref": "x",
        "yref": "y",
        "sizex": 580,
        "sizey": 21.599999999999998,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:logo:17"
      }
    ]
  }
}
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withLogos } from "@sportsdataverse/sdvplot/plotly";

await loadLeague("nfl");
const rows = STANDINGS.map((s) => ({ ...s }));
const figure = {
data: [{ type: "scatter", mode: "markers", x: rows.map((r) => r.pf), y: rows.map((r) => r.pa) }],
layout: { width: 560, height: 400, xaxis: { range: [260, 550] }, yaxis: { range: [280, 460] } },
};

// Each logo is one entry of layout.images, which plotly.js loads itself.
const logos = withLogos(figure, rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.12 });
// In the browser: Plotly.newPlot(div, data, layout), as this page does. Without JavaScript the page shows the figure.
export const browser = {
lib: "plotly",
figure: logos,
label: "2024 AFC points for against points against, each team drawn as its logo",
} as const;

logos;

withAxisLogos puts logos under an axis's ticks and teamColorway gives one team colour per bar; withHeadshots and withWordmarks place images at each row's point.

With JavaScript on, plotly.js draws this figure here.

{
  "data": [
    {
      "type": "bar",
      "x": [
        "KC",
        "BUF",
        "LAC",
        "DEN",
        "MIA",
        "NYJ",
        "LV",
        "NE"
      ],
      "y": [
        15,
        13,
        11,
        10,
        8,
        5,
        4,
        4
      ],
      "marker": {
        "color": [
          "#e31837",
          "#00338d",
          "#007bc7",
          "#002244",
          "#008e97",
          "#003f2d",
          "#000000",
          "#002244"
        ]
      }
    }
  ],
  "layout": {
    "width": 560,
    "height": 360,
    "xaxis": {
      "tickmode": "array",
      "showticklabels": false,
      "tickvals": [
        "KC",
        "BUF",
        "LAC",
        "DEN",
        "MIA",
        "NYJ",
        "LV",
        "NE"
      ],
      "ticktext": [
        "KC",
        "BUF",
        "LAC",
        "DEN",
        "MIA",
        "NYJ",
        "LV",
        "NE"
      ]
    },
    "margin": {
      "b": 97
    },
    "images": [
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/3d/3d77958dc6373768919bb2681cbe1b143f56c07a1f013460def665a5026a7f3d.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:12",
        "x": 0,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/79/79b71e2f536ee29f9d23834e89828883af2d95bf6968cbd07a505444229cdd20.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:2",
        "x": 1,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/54/5400f85bd93129c056717a771da57a97225e15f39c0022107ce89ef993f15bb0.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:24",
        "x": 2,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/c9/c98bec2be32e27b19f79f5da86ac6ef133c78d75ab78aadb28ef36696c3213e8.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:7",
        "x": 3,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/b9/b9631269a82abda39bd748afc82390679ca67bfc167bc3581665e237407de8e0.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:15",
        "x": 4,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/5d/5de8d029f3602c97ffe02bd636eb2630e55d019d1a37cf819d3b9e39a219487b.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:20",
        "x": 5,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/25/25fbb03e972ae872fa024026b73c7b63ef9f23c2f2c51f87d1614d800dcee6e7.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:13",
        "x": 6,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/da/dac19aa9a573dcf9075e2422bee3e35a4955b9ab494bb3191ab3bacfd3609729.png",
        "sizing": "contain",
        "layer": "above",
        "name": "sdvplot:axis:x:17",
        "x": 7,
        "y": 0,
        "xref": "x",
        "yref": "y domain",
        "sizex": 16,
        "sizey": 0.1,
        "xanchor": "center",
        "yanchor": "top"
      }
    ]
  }
}
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorway, withAxisLogos } from "@sportsdataverse/sdvplot/plotly";

await loadLeague("nfl");
const teams = [...STANDINGS].sort((a, b) => b.wins - a.wins).map((s) => s.team);
const figure = {
data: [
{
type: "bar",
x: teams,
y: teams.map((t) => STANDINGS.find((s) => s.team === t)?.wins),
// one colour per bar; as `layout.colorway`, Plotly would cycle them per trace instead
marker: { color: teamColorway("nfl", teams) },
},
],
layout: { width: 560, height: 360 },
};

// The axis keeps its categories; their labels are hidden, not blanked, so hovering a bar names its team.
const logos = withAxisLogos(figure, "x", { league: "nfl", height: 0.1 });
// In the browser: Plotly.newPlot(div, data, layout), as this page does. Without JavaScript the page shows the figure.
export const browser = {
lib: "plotly",
figure: logos,
label: "2024 AFC wins by team, best first, each bar in its team's colour with the team's logo under it",
} as const;

logos;

With JavaScript on, plotly.js draws this figure here.

{
  "data": [
    {
      "type": "bar",
      "x": [
        "KC",
        "BUF",
        "LAC",
        "DEN",
        "MIA",
        "NYJ",
        "LV",
        "NE"
      ],
      "y": [
        15,
        13,
        11,
        10,
        8,
        5,
        4,
        4
      ],
      "marker": {
        "color": "#e8e8e8"
      }
    }
  ],
  "layout": {
    "width": 560,
    "height": 360,
    "yaxis": {
      "range": [
        0,
        18
      ]
    },
    "xaxis": {
      "range": [
        -0.8797086891009549,
        7.879708689100955
      ],
      "autorange": false
    },
    "images": [
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png",
        "x": 0,
        "y": 16.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:3139477"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3918298.png",
        "x": 1,
        "y": 14.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:3918298"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038941.png",
        "x": 2,
        "y": 12.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:4038941"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4426338.png",
        "x": 3,
        "y": 11.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:4426338"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4241479.png",
        "x": 4,
        "y": 9.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:4241479"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/8439.png",
        "x": 5,
        "y": 6.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:8439"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038524.png",
        "x": 6,
        "y": 5.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:4038524"
      },
      {
        "source": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4431452.png",
        "x": 7,
        "y": 5.7,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 2.5200000000000005,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:headshot:4431452"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/16/16a2c7e0cd5dcf2e6bdc646c0b22bf03e575f505aa4b843a0ebfdaf19650257b.png",
        "x": 0,
        "y": 7.5,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:12"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/e3/e363b5d81c20f22a336be435f5ce14d5c86829f956064ec9b85c332661d42ac1.png",
        "x": 1,
        "y": 6.5,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:2"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/ad/ad87ef1e14816a93f9cf06e2f356c526902186abfc4c729e8e01088a84e98cb7.png",
        "x": 2,
        "y": 5.5,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:24"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/dd/ddced0c3708afd6977d26e85c6efd4ddf3f045fb4527dd88e940e5952653875d.png",
        "x": 3,
        "y": 5,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:7"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/cf/cf20b7c2f3fbdbd3d67e6067495c1eae5dfc1d5e22aadb40c574da8fd4abcba0.png",
        "x": 4,
        "y": 4,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:15"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/82/8215dbe17aa824c97de83f1a27d744dc2140e9fe69b42dab5c422738e78e0866.png",
        "x": 5,
        "y": 2.5,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:20"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/c2/c22afa5471cd8e08e9c42f06df3d90f6b88b1b95fc16b4da5e610737a8fe7dac.png",
        "x": 6,
        "y": 2,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:13"
      },
      {
        "source": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/d8/d898a07bacac59e21dc35f7ad3d77e1d6fe57d21dd7000aa9ae9617ea949c7c4.png",
        "x": 7,
        "y": 2,
        "xref": "x",
        "yref": "y",
        "sizex": 17.51883475640382,
        "sizey": 0.9,
        "sizing": "contain",
        "xanchor": "center",
        "yanchor": "middle",
        "opacity": 1,
        "layer": "above",
        "name": "sdvplot:wordmark:17"
      }
    ]
  }
}
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withHeadshots, withWordmarks } from "@sportsdataverse/sdvplot/plotly";

await loadLeague("nfl");
// Each image sits at a row's (x, y): the headshot just above the bar, the wordmark halfway up it.
const rows = [...STANDINGS]
.sort((a, b) => b.wins - a.wins)
.map((s) => ({ ...s, qb_y: s.wins + 1.7, mark_y: s.wins / 2 }));
const figure = {
data: [
{ type: "bar", x: rows.map((r) => r.team), y: rows.map((r) => r.wins), marker: { color: "#e8e8e8" } },
],
layout: { width: 560, height: 360, yaxis: { range: [0, 18] } },
};
const headshots = withHeadshots(figure, rows, {
league: "nfl",
x: "team",
y: "qb_y",
player: "qb_espn_id",
height: 0.14,
});

const both = withWordmarks(headshots, rows, {
league: "nfl",
x: "team",
y: "mark_y",
team: "team",
height: 0.05,
});
// In the browser: Plotly.newPlot(div, data, layout), as this page does; plotly.js loads each layout image itself.
// Without JavaScript the page shows the figure.
export const browser = {
lib: "plotly",
figure: both,
label:
"2024 AFC wins by team, each bar topped by its most frequent starting quarterback's headshot, the team's wordmark inside it",
} as const;

both;

Vega-Lite​

The same verbs add a native image layer to a Vega-Lite spec; compile it and render it as usual. logoLayer is that layer on its own, to drop into a layered spec of your own, and teamColorScale gives a colour scale of team colours.

260280300320340360380400420440460480500520540560Points for280300320340360380400420440460Points against
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withLogos } from "@sportsdataverse/sdvplot/vega";
import * as vega from "vega";
import { type TopLevelSpec, compile } from "vega-lite";

await loadLeague("nfl");
const rows = STANDINGS.map((s) => ({ ...s }));
const chart: TopLevelSpec = {
width: 480,
height: 320,
data: { values: rows },
mark: { type: "point", opacity: 0 },
encoding: {
x: { field: "pf", type: "quantitative", scale: { domain: [260, 560] }, title: "Points for" },
y: { field: "pa", type: "quantitative", scale: { domain: [280, 460] }, title: "Points against" },
},
};
const spec = withLogos(chart, rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.12 });

// In the browser: vegaEmbed(div, spec, { renderer: "svg" }), as this page does (the SVG names each mark, "KC logo").
export const browser = { lib: "vega", spec } as const;

// Headless Vega: no DOM and no canvas, so the logos stay <image href> links the browser loads when it shows the SVG.
const view = new vega.View(vega.parse(compile(browser.spec).spec), { renderer: "none" });
await view.toSVG();
260280300320340360380400420440460480500520540560Points for280300320340360380400420440460Points against
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoLayer } from "@sportsdataverse/sdvplot/vega";
import * as vega from "vega";
import { type TopLevelSpec, compile } from "vega-lite";

await loadLeague("nfl");
const rows = STANDINGS.map((s) => ({ ...s }));
// the dashed lines: these 8 teams' mean points for and against
const mean = (k: "pf" | "pa") => rows.reduce((t, r) => t + r[k], 0) / rows.length;
const height = 320;
// logoLayer is one image layer (its fields are "x" and "y") to drop into any layer list; the scales are shared.
const spec: TopLevelSpec = {
width: 480,
height,
layer: [
{
data: { values: [{ pf: mean("pf"), pa: mean("pa") }] },
mark: { type: "rule", strokeDash: [4, 4], color: "#888" },
encoding: {
x: { field: "pf", type: "quantitative", title: "Points for", scale: { domain: [260, 560] } },
},
},
{
data: { values: [{ pf: mean("pf"), pa: mean("pa") }] },
mark: { type: "rule", strokeDash: [4, 4], color: "#888" },
encoding: {
y: { field: "pa", type: "quantitative", title: "Points against", scale: { domain: [280, 460] } },
},
},
logoLayer(rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.12, chartHeight: height }),
],
};

// In the browser: vegaEmbed(div, spec, { renderer: "svg" }), as this page does (the SVG names each mark, "KC logo").
export const browser = { lib: "vega", spec } as const;

const view = new vega.View(vega.parse(compile(browser.spec).spec), { renderer: "none" });
await view.toSVG();
0246810121416Wins (2024)
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorScale, withAxisLogos } from "@sportsdataverse/sdvplot/vega";
import * as vega from "vega";
import { type TopLevelSpec, compile } from "vega-lite";

await loadLeague("nfl");
const rows = STANDINGS.map((s) => ({ ...s }));
const teams = [...rows].sort((a, b) => b.wins - a.wins).map((r) => r.team);
const chart: TopLevelSpec = {
width: 480,
height: 300,
data: { values: rows },
mark: "bar",
encoding: {
x: { field: "team", type: "nominal", sort: teams, title: null },
y: { field: "wins", type: "quantitative", title: "Wins (2024)" },
color: { field: "team", type: "nominal", scale: teamColorScale("nfl", teams), legend: null },
},
};
// The axis keeps its categories: their labels are blanked and an image layer draws a logo under each tick.
const spec = withAxisLogos(chart, "x", { league: "nfl", height: 0.1 });

// In the browser: vegaEmbed(div, spec, { renderer: "svg" }), as this page does (the SVG names each mark, "KC logo").
export const browser = { lib: "vega", spec } as const;

// Headless Vega: no DOM and no canvas, so the logos stay <image href> links the browser loads when it shows the SVG.
const view = new vega.View(vega.parse(compile(browser.spec).spec), { renderer: "none" });
await view.toSVG();
024681012141618Wins (2024)
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withHeadshots, withWordmarks } from "@sportsdataverse/sdvplot/vega";
import * as vega from "vega";
import { type TopLevelSpec, compile } from "vega-lite";

await loadLeague("nfl");
// Each image sits at a row's (x, y): the headshot just above the bar, the wordmark halfway up it.
const rows = [...STANDINGS]
.sort((a, b) => b.wins - a.wins)
.map((s) => ({ ...s, qb_y: s.wins + 1.7, mark_y: s.wins / 2 }));
const chart: TopLevelSpec = {
width: 480,
height: 300,
data: { values: rows },
mark: { type: "bar", color: "#e8e8e8" },
encoding: {
x: { field: "team", type: "nominal", sort: null, axis: null },
y: { field: "wins", type: "quantitative", scale: { domain: [0, 18] }, title: "Wins (2024)" },
},
};
const headshots = withHeadshots(chart, rows, {
league: "nfl",
x: "team",
y: "qb_y",
player: "qb_espn_id",
height: 0.14,
});
const spec = withWordmarks(headshots, rows, {
league: "nfl",
x: "team",
y: "mark_y",
team: "team",
height: 0.05,
});

// In the browser: vegaEmbed(div, spec, { renderer: "svg" }), as this page does (the SVG names each mark, "KC logo").
export const browser = { lib: "vega", spec } as const;

const view = new vega.View(vega.parse(compile(browser.spec).spec), { renderer: "none" });
await view.toSVG();

ECharts​

For ECharts the verbs add a custom series that draws the images, sized from the grid at render time; axis logos become rich-text labels and teamColorPalette fills option.color. Server-side rendering to an SVG string works as in any ECharts project.

Points against Points for 270 300 330 360 390 420 450 260 300 350 400 450 500 550
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withLogos } from "@sportsdataverse/sdvplot/echarts";
import * as echarts from "echarts";

await loadLeague("nfl");
const rows = STANDINGS.map((s) => ({ ...s }));
const option: echarts.EChartsOption = {
xAxis: { type: "value", name: "Points for", nameLocation: "middle", nameGap: 28, min: 260, max: 550 },
yAxis: { type: "value", name: "Points against", nameLocation: "middle", nameGap: 40, min: 270, max: 450 },
series: [{ type: "scatter", data: rows.map((r) => [r.pf, r.pa]), symbolSize: 0 }],
};

// In the browser: echarts.init(div, null, { width, height }) and setOption(option), as this page does.
export const browser = {
lib: "echarts",
option: withLogos(option, rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.12 }),
width: 560,
height: 380,
label: "2024 AFC points for against points against, each team drawn as its logo",
} as const;

// SSR: no DOM, no canvas. The logos are <image href> links the browser loads when it shows the SVG.
const { width, height } = browser;
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width, height });
chart.setOption(browser.option);
const svg = chart.renderToSVGString();
chart.dispose();
svg;
Wins (2024) 0 3 6 9 12 15
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorPalette, withAxisLogos } from "@sportsdataverse/sdvplot/echarts";
import * as echarts from "echarts";

await loadLeague("nfl");
const teams = [...STANDINGS].sort((a, b) => b.wins - a.wins);
const names = teams.map((t) => t.team);
const option: echarts.EChartsOption = {
tooltip: {}, // in the browser, hovering a bar shows its team and wins
color: teamColorPalette("nfl", names), // with colorBy "data", one colour per bar
xAxis: { type: "category", data: names },
yAxis: { type: "value", name: "Wins (2024)" },
series: [{ type: "bar", colorBy: "data", data: teams.map((t) => t.wins) }],
};

// In the browser: echarts.init(div, null, { width, height }) and setOption(option), as this page does.
export const browser = {
lib: "echarts",
option: withAxisLogos(option, "x", { league: "nfl", height: 0.1, chartHeight: 360 }),
width: 560,
height: 360,
label: "2024 AFC wins by team, best first, each bar in its team's colour with the team's logo under it",
} as const;

// SSR: no DOM, no canvas. The logos are <image href> links the browser loads when it shows the SVG.
const { width, height } = browser;
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width, height });
chart.setOption(browser.option);
const svg = chart.renderToSVGString();
chart.dispose();
svg;
Wins (2024) 0 3 6 9 12 15 18
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { withHeadshots, withWordmarks } from "@sportsdataverse/sdvplot/echarts";
import * as echarts from "echarts";

await loadLeague("nfl");
// Each image sits at a row's (x, y): the headshot just above the bar, the wordmark halfway up it.
const rows = [...STANDINGS]
.sort((a, b) => b.wins - a.wins)
.map((s) => ({ ...s, qb_y: s.wins + 1.7, mark_y: s.wins / 2 }));
const option: echarts.EChartsOption = {
tooltip: {}, // in the browser, hovering a bar shows its team and wins
xAxis: { type: "category", data: rows.map((r) => r.team), axisLabel: { show: false } },
yAxis: { type: "value", name: "Wins (2024)", max: 18 },
series: [{ type: "bar", data: rows.map((r) => r.wins), itemStyle: { color: "#e8e8e8" } }],
};
const headshots = withHeadshots(option, rows, {
league: "nfl",
x: "team",
y: "qb_y",
player: "qb_espn_id",
height: 0.14,
});
const both = withWordmarks(headshots, rows, {
league: "nfl",
x: "team",
y: "mark_y",
team: "team",
height: 0.05,
});
// In the browser: echarts.init(div, null, { width, height }) and setOption(option), as this page does.
export const browser = {
lib: "echarts",
option: both,
width: 560,
height: 360,
label:
"2024 AFC wins by team, each bar topped by its most frequent starting quarterback's headshot, the team's wordmark inside it",
} as const;

// SSR: no DOM, no canvas. The images are <image href> links the browser loads when it shows the SVG.
const { width, height } = browser;
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width, height });
chart.setOption(both);
const svg = chart.renderToSVGString();
chart.dispose();
svg;

Images that travel with the file​

By default every image is a link the viewer's browser loads. embedSources(urls) fetches each URL once and returns a map from URL to data URI; pass it as embed and the spec carries the image bytes instead, for a saved file, an email or a page that has to work offline. Each of the three subpaths exports it.

import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, logoUrlSync } from "@sportsdataverse/sdvplot";
import { embedSources, withLogos } from "@sportsdataverse/sdvplot/echarts";
import * as echarts from "echarts";

await loadLeague("nfl");
// The AFC West's two playoff teams of 2024.
const rows = STANDINGS.filter((s) => s.division === "West" && s.wins >= 11).map((s) => ({ ...s }));

// embedSources fetches each distinct URL once and maps it to a data URI; `embed` swaps each link for its bytes,
// so the SVG below is one self-contained file (a static export, an email, an offline page).
const embed = await embedSources(rows.flatMap((r) => logoUrlSync(r.team, "nfl") ?? []));

const option: echarts.EChartsOption = {
xAxis: { type: "value", name: "Points for", nameLocation: "middle", nameGap: 28, min: 370, max: 420 },
yAxis: { type: "value", name: "Points against", nameLocation: "middle", nameGap: 40, min: 290, max: 340 },
series: [{ type: "scatter", data: rows.map((r) => [r.pf, r.pa]), symbolSize: 0 }],
};
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width: 420, height: 320 });
chart.setOption(
withLogos(option, rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.25, embed }),
);
const svg = chart.renderToSVGString();
chart.dispose();
svg;
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, logoUrlSync } from "@sportsdataverse/sdvplot";
import { embedSources, withLogos } from "@sportsdataverse/sdvplot/vega";
import * as vega from "vega";
import { type TopLevelSpec, compile } from "vega-lite";

await loadLeague("nfl");
// The AFC West's two playoff teams of 2024.
const rows = STANDINGS.filter((s) => s.division === "West" && s.wins >= 11).map((s) => ({ ...s }));
// One fetch per distinct logo; the image layer then carries the bytes instead of the link.
const embed = await embedSources(rows.flatMap((r) => logoUrlSync(r.team, "nfl") ?? []));

const chart: TopLevelSpec = {
width: 360,
height: 260,
data: { values: rows },
mark: { type: "point", opacity: 0 },
encoding: {
x: { field: "pf", type: "quantitative", scale: { domain: [370, 420] }, title: "Points for" },
y: { field: "pa", type: "quantitative", scale: { domain: [290, 340] }, title: "Points against" },
},
};
const spec = withLogos(chart, rows, { league: "nfl", x: "pf", y: "pa", team: "team", height: 0.25, embed });
const view = new vega.View(vega.parse(compile(spec).spec), { renderer: "none" });
await view.toSVG();
[
  {
    "points for, against": "385, 326",
    "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAf… (53662 characters)"
  },
  {
    "points for, against": "402, 301",
    "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAf… (43822 characters)"
  }
]
import { STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague, logoUrlSync } from "@sportsdataverse/sdvplot";
import { type PlotlyLayout, embedSources, withLogos } from "@sportsdataverse/sdvplot/plotly";

await loadLeague("nfl");
// The AFC West's two playoff teams of 2024.
const rows = STANDINGS.filter((s) => s.division === "West" && s.wins >= 11).map((s) => ({ ...s }));
// A figure saved to a file (JSON, an HTML page) shows its logos offline only if their bytes travel with it.
const embed = await embedSources(rows.flatMap((r) => logoUrlSync(r.team, "nfl") ?? []));
const layout: PlotlyLayout = {};
const data = [{ type: "scatter", mode: "markers", x: rows.map((r) => r.pf), y: rows.map((r) => r.pa) }];
const figure = withLogos({ data, layout }, rows, {
league: "nfl",
x: "pf",
y: "pa",
team: "team",
height: 0.25,
embed,
});

// Each layout image's source is now the PNG itself (shown cut short; the full figure goes to Plotly.newPlot).
(figure.layout.images ?? []).map((im) => ({
"points for, against": `${im.x}, ${im.y}`,
source: `${im.source.slice(0, 48)}… (${im.source.length} characters)`,
}));

Chart.js​

teamColor and teamFill from @sportsdataverse/sdvplot/chartjs return Chart.js colours (one per team) for backgroundColor and borderColor; teamFill is the translucent version for areas. These examples draw on @napi-rs/canvas in Node and register a bundled font first, since a build server may have none; in your browser the same config is drawn on a canvas with the same font.

2024 AFC points for (solid) and against (outlined) by team
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}">`;

surface(league, { team }) from @sportsdataverse/sdvplot/chartjs/surface returns a plugin that paints the court under the datasets and scales that keep a foot the same length on both axes.

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
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}">`;

The image helpers (logoPoints, wordmarkPoints, headshotPoints, axisLogos, logoWatermarks) build <img> elements in the browser. Give them a loadImage option and they load each image through it instead, which is how these examples draw them in Node: the loader fetches the mark and decodes it with @napi-rs/canvas, and the example awaits the loads before taking the PNG. Point styles also need the pointImages plugin, which updates the chart as each image lands.

The 2024 AFC West as team logos, points for against points against: Kansas City, the Chargers, Denver and Las Vegas
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}">`;

axisLogos("x") paints a category axis's team labels as logos and blanks their text.

2024 AFC West wins in team colours, each bar labelled by its team's logo: Kansas City 15, the Chargers 11, Denver 10, Las Vegas 4
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 AxisLogoOptions, axisLogos, teamColor } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";

await loadLeague("nfl");
const west = STANDINGS.filter((s) => s.division === "West");
const teams = west.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). A
// browser loads each logo itself; Node passes `loadImage` (below).
const config = (o: Pick<AxisLogoOptions, "loadImage"> = {}): ChartConfiguration<"bar"> => ({
type: "bar",
data: {
labels: teams,
datasets: [{ data: west.map((s) => s.wins), backgroundColor: teamColor(teams, "nfl") }],
},
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: { y: { min: 0, max: 16, ticks: { stepSize: 4 }, title: { display: true, text: "Wins" } } },
},
// the x axis is a category scale: each label that names a team is painted as its logo, its text blanked
plugins: [axisLogos("x", { league: "nfl", size: 36, ...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:
"2024 AFC West wins in team colours, each bar labelled by its team's logo: Kansas City 15, the Chargers 11, Denver 10, Las Vegas 4",
} 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, axisLogos 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 }));
// after new Chart(...): axisLogos asks for its images while the chart is first laid out
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}">`;

wordmarkPoints does the same with wordmarks, about 3.6 times as wide as they are tall, and headshotPoints with ESPN headshots, built from the player id alone. ESPN's image server resizes on request, so this loader asks for a small copy rather than the full-size original.

The 2024 AFC West as team wordmarks, wins against point differential: Kansas City 15 and +59, the Chargers 11 and +101, Denver 10 and +114, Las Vegas 4 and -125
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, pointImages, wordmarkPoints } 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 wordmark itself; Node passes `loadImage` (below).
const config = (o: Pick<PointOptions, "loadImage"> = {}): ChartConfiguration<"scatter"> => ({
type: "scatter",
data: {
datasets: [
{
data: west.map((s) => ({ x: s.wins, y: s.pf - s.pa })),
// a wordmark is about 3.6 times as wide as it is tall: radius sets the height (2 × radius px)
...wordmarkPoints(
west.map((s) => s.team),
{ league: "nfl", radius: 12, ...o },
),
},
],
},
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { min: 2, max: 18, title: { display: true, text: "Wins" } },
y: { min: -150, max: 150, title: { display: true, text: "Points for minus points against" } },
},
},
plugins: [pointImages],
});
// In the browser: new Chart(canvas, config()) on a 640 x 360 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 360,
label:
"The 2024 AFC West as team wordmarks, wins against point differential: Kansas City 15 and +59, the Chargers 11 and +101, Denver 10 and +114, Las Vegas 4 and -125",
} 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="360" alt="${browser.label}">`;
Headshots of the 2024 AFC West's most frequent starting quarterbacks, Patrick Mahomes, Justin Herbert, Bo Nix and Gardner Minshew, at their teams' points for and wins
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 { type HeadshotPointOptions, headshotPoints, pointImages } from "@sportsdataverse/sdvplot/chartjs";
import { Chart, type ChartConfiguration, registerables } from "chart.js";

// ESPN player ids need no league data: headshotPoints builds each URL from the id alone.
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 headshot itself; Node passes `loadImage` (below).
const config = (o: Pick<HeadshotPointOptions, "loadImage"> = {}): ChartConfiguration<"scatter"> => ({
type: "scatter",
data: {
datasets: [
{
data: west.map((s) => ({ x: s.pf, y: s.wins })),
// each team's most frequent starter, 2 × radius px tall (an ESPN headshot is 600:436, so a little wider)
...headshotPoints(
west.map((s) => s.qb_espn_id),
{ league: "nfl", radius: 24, ...o },
),
},
],
},
options: {
responsive: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: { min: 280, max: 440, title: { display: true, text: "Points for" } },
y: { min: 0, max: 18, title: { display: true, text: "Wins" } },
},
},
plugins: [pointImages],
});
// In the browser: new Chart(canvas, config()) on a 640 x 360 canvas, as this page does.
export const browser = {
lib: "chartjs",
config,
width: 640,
height: 360,
label:
"Headshots of the 2024 AFC West's most frequent starting quarterbacks, Patrick Mahomes, Justin Herbert, Bo Nix and Gardner Minshew, at their teams' points for and wins",
} as const;

// ESPN's image combiner resizes on request, so the loader asks for twice the drawn size rather than the 1096 px
// original; it fetches each URL and decodes it on @napi-rs/canvas. Keep the promises: once they settle,
// pointImages has redrawn.
const loads: Promise<Image>[] = [];
const load = (url: string): Promise<Image> => {
const p = fetch(`${url}&w=96&h=70`)
.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="360" alt="${browser.label}">`;

A matchup in two colours​

matchupColors(home, away) returns a pair that reads apart on a light page and one for a dark page; here the light pair colours Super Bowl LIX's win probability, and logoWatermarks puts the two teams' logos faintly behind it.

Super Bowl LIX win probability, Philadelphia and Kansas City, by minute, with faint Eagles and Chiefs logos behind the lines
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}">`;

In the gallery: Plotly, Vega-Lite, ECharts and Chart.js.