Skip to main content

Hockey (NHL / PWHL)

Hockey event data arrives on many canvases (HockeyTech's pixel grids, the NHL's feet from centre ice), and the rink differs by league. Each figure on this page is drawn by the code under it, and the same code runs in CI on every pull request; in Node, see Node and SSR.

Shot map: a rink in team colours, dots, logos​

Every shot of the first period of game 7 of the 2024 Stanley Cup Final (Edmonton at Florida), from NHL api-web (see Sample data). The NHL reports shots in feet from centre ice, the frame sporty's rink is drawn in, so they plot as captured on a rink painted in Florida's colours.

import * as Plot from "@observablehq/plot";
import { NHL_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logos, surface, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nhl");

// NHL api-web reports xCoord/yCoord in feet from centre ice, the frame sporty's NHL rink is drawn in, so the shots
// plot as captured with no toSurfaceFrame. In this period Florida shot at the -x end and Edmonton at the +x end.
const rink = surface("nhl", { team: "FLA" });
const at = { x: "x", y: "y", r: 5 } as const;
Plot.plot({
...rink.scales,
width: 940,
color: teamColor("nhl", { values: NHL_SHOTS.map((s) => s.team), legend: true }),
marks: [
...rink.marks,
// goals and shots on goal filled in the shooting team's colour, misses white with a ring in it
Plot.dot(
NHL_SHOTS.filter((s) => s.type !== "missed-shot"),
{ ...at, fill: "team", stroke: "white" },
),
Plot.dot(
NHL_SHOTS.filter((s) => s.type === "missed-shot"),
{ ...at, fill: "white", stroke: "team", strokeWidth: 2 },
),
logos(
NHL_SHOTS.filter((s) => s.type === "goal"),
{ league: "nhl", x: "x", y: "y", team: "team", height: 0.08 },
),
],
});

HockeyTech canvases​

HockeyTech leagues report events on a 600 × 300 pixel canvas (origin top-left, y down), and the hockeytech frame maps it onto the rink. The canvas is stylised rather than drawn to scale, so the converted feet are approximate. A PWHL game's goals go through it here (see Sample data).

{
  "hockeytech": "HockeyTech 600x300 canvas, top-left origin -> 200x85 ft centre origin (fastRhockey hockeytech_analytics: x/3-100, 42.5-y*85/300). The canvas is stylised rather than true to scale: converted end-zone faceoff dots land at about ±67 ft and ±52 ft against regulation ±69 ft, so the converted feet are approximate",
  "PWHL Boston at Montreal, 2024-03-02 (game 42): goals": [
    "MTL Marie-Philip Poulin, P1 3:51: (90, 123) -> (-70.0, 7.7) ft",
    "MTL Mélodie Daoust, P2 4:50: (57, 105) -> (-81.0, 12.8) ft",
    "BOS Hilary Knight, P2 14:55: (470, 117) -> (56.7, 9.3) ft",
    "MTL Erin Ambrose, P3 2:29: (196, 61) -> (-34.7, 25.2) ft"
  ]
}
import { PWHL_GOALS } from "@sportsdataverse/examples/data";
import { FRAMES, toSurfaceFrame } from "@sportsdataverse/sporty";

// HockeyTech leagues report events on a 600 x 300 pixel canvas (top-left origin, y down); the hockeytech frame maps
// it onto a 200 x 85 ft rink with its centre at 0, 0. PWHL game 42's shots span x 31-573 and y 11-292.
const goals = toSurfaceFrame(PWHL_GOALS, { from: "hockeytech" });

{
hockeytech: FRAMES.hockeytech.description,
"PWHL Boston at Montreal, 2024-03-02 (game 42): goals": goals.map(
(r) =>
`${r.team} ${r.scorer}, P${r.period} ${r.time}: (${r.x}, ${r.y}) -> (${r.surface_x?.toFixed(1)}, ${r.surface_y?.toFixed(1)}) ft`,
),
};

Display ranges​

A display range crops the rink to the part a figure shows; several names are aliases of one box.

import * as Plot from "@observablehq/plot";
import { displayRanges, surface } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";

// A display range only changes the scene's bbox (the part of the rink a figure shows). Several names are
// aliases ("offense", "offence"), so group them by the box they produce and outline each box once.
const boxes = new Map<string, { bbox: readonly number[]; names: string[] }>();
for (const name of displayRanges("hockey")) {
const { bbox } = surface("hockey", "nhl", { displayRange: name });
const key = bbox.join();
const box = boxes.get(key) ?? { bbox, names: [] };
box.names.push(name);
boxes.set(key, box);
}
const rows = [...boxes.values()].map(({ bbox: [x1 = 0, y1 = 0, x2 = 0, y2 = 0], names }, i) => ({
x1: x1 + i,
y1: y1 + i,
x2: x2 - i,
y2: y2 - i,
label: names.filter((n) => !n.includes(" ")).join(" / "),
}));

const rink = surface("hockey", "nhl", { arcResolution: 32 }); // 32 points per arc is plenty at 900 px
Plot.plot({
...surfaceScales(rink),
width: 900,
color: { type: "categorical", legend: true },
marks: [
...surfaceMark(rink),
Plot.rect(rows, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
stroke: "label",
strokeWidth: 2,
fill: "none",
}),
],
});
{
  "arcResolution: 12": {
    "points": 1888,
    "toSVG": "33.0 KB",
    "toSVG arcs: \"svg\"": "14.8 KB"
  },
  "arcResolution: 200 (default)": {
    "points": 24824,
    "toSVG": "446.6 KB",
    "toSVG arcs: \"svg\"": "32.6 KB"
  }
}
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";

// Every circle and corner is sampled as `arcResolution` points (default 200). Fewer points, smaller output;
// toSVG's `arcs: "svg"` writes detected circles as arc commands, so it shrinks the file at any resolution.
const kb = (s: string): string => `${(new TextEncoder().encode(s).length / 1024).toFixed(1)} KB`;
const cost = (arcResolution: number) => {
const rink = surface("hockey", "nhl", { arcResolution });
let points = 0;
for (const f of rink.features) if (f.kind === "polygon") points += f.points.length;
return { points, toSVG: kb(toSVG(rink)), 'toSVG arcs: "svg"': kb(toSVG(rink, { arcs: "svg" })) };
};

{ "arcResolution: 12": cost(12), "arcResolution: 200 (default)": cost(200) };

Standings: bars with logos on the axis​

020406080100↑ points
Atlantic Division points, 2025-26 regular season. Data: NHL api-web
import * as Plot from "@observablehq/plot";
import { NHL_STANDINGS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { axisLogos, teamColor } from "@sportsdataverse/sdvplot/plot";

await loadLeague("nhl");
Plot.plot({
height: 300,
caption: "Atlantic Division points, 2025-26 regular season. Data: NHL api-web",
marks: [
Plot.barY(NHL_STANDINGS, { x: "team", y: "points", fill: "team", sort: { x: "-y" } }),
axisLogos("x", { league: "nhl", height: 0.12 }),
],
color: teamColor("nhl", { values: NHL_STANDINGS.map((s) => s.team) }),
});

Every hockey rink​

NHL, PWHL, AHL, ECHL, IIHF, NCAA, OHL, QMJHL, USHL, PHF and NWHL.