Playing surfaces
@sportsdataverse/sporty is a port of sportyR: the courts, rinks, fields,
pitches and sheets of nine sports, built to each league's published dimensions. A surface is a scene of polygons in
the league's units, centred on the middle of the playing area, so tracking or event data in those units lands where
it happened. Build the scene once, then draw it with whichever renderer you use, and move your data onto it with a
coordinate frame.
One call, any sport
surface(sport, league, options) returns a scene; toSVG turns it into an SVG string. The options are typed per
sport, so a display range such as "offense" is checked against basketball's list.
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
const rink = surface("hockey", "nhl", { displayRange: "offense" });
toSVG(rink, { width: 480, precision: 2, arcs: "svg" });
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// One entry point for the nine sports; options are typed per sport, so "offense" is checked against basketball.
const court = surface("basketball", "nba", { displayRange: "offense" });
// `arcs: "svg"` writes each circle as an arc command instead of 200 sampled points: same drawing, smaller file.
toSVG(court, { width: 480, arcs: "svg" });
Each sport also has its own entry point (basketballCourt, hockeyRink, footballField, soccerPitch,
baseballField, tennisCourt, volleyballCourt, curlingSheet, lacrosseField), typed to that sport's leagues.
import {
baseballField,
basketballCourt,
curlingSheet,
footballField,
hockeyRink,
lacrosseField,
soccerPitch,
tennisCourt,
volleyballCourt,
} from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// Each sport also has its own entry point, typed to that sport's leagues and options.
const scenes = [
basketballCourt("nba"),
hockeyRink("nhl"),
footballField("nfl"),
soccerPitch("epl"),
baseballField("mlb"),
tennisCourt("atp"),
volleyballCourt("fivb"),
curlingSheet("wcf"),
lacrosseField("nll"),
];
const cells = scenes.map(
(s) =>
`<figure style="margin:0">${toSVG(s, { width: 200, arcs: "svg", precision: 2 })}<figcaption>${s.sport}: ${s.league}</figcaption></figure>`,
);
`<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:end">${cells.join("")}</div>`;
What each sport offers
leagues, displayRanges, features and colorKeys list what a sport accepts; they are the lists the option types
are built from. The numbers behind each league are in the *_SPECS tables, also exported without the drawing code
from @sportsdataverse/sporty/specs.
{
"baseball": {
"leagues": "custom, little league, milb, mlb, ncaa, nfhs, pony",
"displayRanges": 2,
"features": 10,
"colorKeys": 10,
"displayRanges (first 6)": "full, infield"
},
"basketball": {
"leagues": "custom, fiba, nba, nba g league, ncaa, nfhs, wnba",
"displayRanges": 49,
"features": 23,
"colorKeys": 26,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, offensivehalfcourt"
},
"curling": {
"leagues": "curling canada, custom, wcf",
"displayRanges": 4,
"features": 12,
"colorKeys": 14,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, house"
},
"football": {
"leagues": "cfl, custom, ncaa, nfhs11, nfhs6, nfhs8, nfhs9, nfl",
"displayRanges": 22,
"features": 21,
"colorKeys": 23,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, offensivehalffield"
},
"hockey": {
"leagues": "ahl, custom, echl, iihf, ncaa, nhl, nwhl, ohl, phf, pwhl, qmjhl, ushl",
"displayRanges": 21,
"features": 24,
"colorKeys": 25,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, defense"
},
"lacrosse": {
"leagues": "custom, ncaam, ncaaw, nll, pll, usam, usaw, world lacrosse",
"displayRanges": 13,
"features": 33,
"colorKeys": 35,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, offensivehalffield"
},
"soccer": {
"leagues": "custom, epl, fifa, mls, ncaa, nwsl",
"displayRanges": 13,
"features": 13,
"colorKeys": 15,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, offensivehalfpitch"
},
"tennis": {
"leagues": "atp, custom, ita, itf, ncaa, usta, wta",
"displayRanges": 22,
"features": 10,
"colorKeys": 13,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, serve, serving, servicehalf"
},
"volleyball": {
"leagues": "custom, fivb, ncaa, usa volleyball",
"displayRanges": 13,
"features": 10,
"colorKeys": 12,
"displayRanges (first 6)": "full, in_bounds_only, in bounds only, offense, offence, offensivehalfcourt"
}
}import { SPORTS, colorKeys, displayRanges, features, leagues } from "@sportsdataverse/sporty";
// The discovery tables are the same lists the option types are built from, so they never drift from the API.
Object.fromEntries(
SPORTS.map((sport) => [
sport,
{
leagues: leagues(sport).join(", "),
displayRanges: displayRanges(sport).length,
features: features(sport).length,
colorKeys: colorKeys(sport).length,
"displayRanges (first 6)": displayRanges(sport).slice(0, 6).join(", "),
},
]),
);
{
"BASKETBALL_SPECS.nba lane": {
"court_units": "ft",
"lane_width": "16, 12",
"lane_length": "19, 19",
"free_throw_line_to_backboard": 15,
"basket_center_to_three_point_arc": "23.75"
},
"@sportsdataverse/sporty/specs": {
"BASEBALL_SPECS": "7 leagues: custom, little league, milb, mlb, ncaa, nfhs, pony",
"BASKETBALL_SPECS": "7 leagues: custom, fiba, nba, nba g league, ncaa, nfhs, wnba",
"CURLING_SPECS": "3 leagues: curling canada, custom, wcf",
"FOOTBALL_SPECS": "8 leagues: cfl, custom, ncaa, nfhs11, nfhs6, nfhs8, nfhs9, nfl",
"HOCKEY_SPECS": "12 leagues: ahl, custom, echl, iihf, ncaa, nhl, nwhl, ohl, phf, pwhl, qmjhl, ushl",
"LACROSSE_SPECS": "8 leagues: custom, ncaam, ncaaw, nll, pll, usam, usaw, world lacrosse",
"SOCCER_SPECS": "6 leagues: custom, epl, fifa, mls, ncaa, nwsl",
"TENNIS_SPECS": "7 leagues: atp, custom, ita, itf, ncaa, usta, wta",
"VOLLEYBALL_SPECS": "4 leagues: custom, fivb, ncaa, usa volleyball"
}
}import { BASKETBALL_SPECS } from "@sportsdataverse/sporty";
import * as specs from "@sportsdataverse/sporty/specs";
// Every league is a row of numbers (sportyR's surface dimensions, in the league's own units).
const nba = BASKETBALL_SPECS.nba;
// @sportsdataverse/sporty/specs exports one *_SPECS table per sport.
const tables = Object.entries(specs).filter(([name]) => name.endsWith("_SPECS"));
{
"BASKETBALL_SPECS.nba lane": {
court_units: nba.court_units,
lane_width: nba.lane_width.join(", "),
lane_length: nba.lane_length.join(", "),
free_throw_line_to_backboard: nba.free_throw_line_to_backboard,
basket_center_to_three_point_arc: nba.basket_center_to_three_point_arc.join(", "),
},
"@sportsdataverse/sporty/specs": Object.fromEntries(
tables.map(([name, table]) => [
name,
`${Object.keys(table).length} leagues: ${Object.keys(table).join(", ")}`,
]),
),
};
Changing the surface
updates overrides any dimension of the league's spec, and colorUpdates recolours any colour key.
import { type BasketballParamUpdates, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// `updates` overrides any parameter of the league's spec; the type lists every one (and rejects typos).
// The NBA lane is 16 ft wide (with a 12 ft inner box); the 1951-1964 lane was 12 ft.
const narrow: BasketballParamUpdates = { lane_width: [12, 12] };
const draw = (label: string, updates?: BasketballParamUpdates): string =>
`<figure style="margin:0">${toSVG(surface("basketball", "nba", { displayRange: "offense", ...(updates ? { updates } : {}) }), { width: 300, arcs: "svg" })}<figcaption>${label}</figcaption></figure>`;
`<div style="display:flex;flex-wrap:wrap;gap:16px">${draw("nba (16 ft lane)")}${draw("updates: { lane_width: [12, 12] }", narrow)}</div>`;
import { colorKeys, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// Any key of colorKeys("basketball") takes a colour; the rest keep the league's defaults.
const court = surface("basketball", "nba", {
colorUpdates: {
painted_area: "#552583",
center_circle_fill: "#552583",
two_point_range: "#fdb927",
three_point_line: "#552583",
},
});
`<figure style="margin:0">${toSVG(court, { width: 560, arcs: "svg" })}<figcaption>4 of the ${colorKeys("basketball").length} basketball colour keys changed</figcaption></figure>`;
displayRange crops the scene to part of the surface (a half, a zone); rotation, xTrans and yTrans move it,
units rebuilds it in another unit, and arcResolution sets how many points each arc is sampled with.
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",
}),
],
});
import * as Plot from "@observablehq/plot";
import { surface } from "@sportsdataverse/sporty";
import { surfaceMark } from "@sportsdataverse/sporty/plot";
// The same court twice: as built (centred on 0, 0), and moved. As in sportyR, xTrans/yTrans shift the court
// first, then `rotation` turns the result about the origin: (80, 10) turned 90 degrees is (-10, 80).
const asBuilt = surface("tennis", "atp");
const moved = surface("tennis", "atp", { rotation: 90, xTrans: 80, yTrans: 10 });
// The axes stay on so the move is visible; both scenes share the plot's feet.
const [ax0, ay0, ax1, ay1] = asBuilt.bbox;
const [bx0, by0, bx1, by1] = moved.bbox;
const centres = [
[0, 0],
[-10, 80],
];
Plot.plot({
width: 520,
aspectRatio: 1,
x: { domain: [Math.min(ax0, bx0), Math.max(ax1, bx1)], label: "x (ft)" },
y: { domain: [Math.min(ay0, by0), Math.max(ay1, by1)], label: "y (ft)" },
marks: [
...surfaceMark(asBuilt),
...surfaceMark(moved),
Plot.dot(centres, { r: 4, fill: "currentColor" }),
Plot.text(centres, { text: ["origin", "(-10, 80)"], dy: -10 }),
],
});
court.units: mcourt.bbox (m): -16.76, -9.14, 16.76, 9.14convertUnits(94, 'ft', 'm'): 28.651convertPoints([[47, 25]], 'ft', 'yd'): [[15.666666666666666,8.333333333333334]]normalizeUnit('Metres'): mFT_PER_UNIT.m: 3.2808
import { FT_PER_UNIT, convertPoints, convertUnits, normalizeUnit, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// `units` rebuilds the scene in another unit: every coordinate and the bbox are in metres here.
const court = surface("basketball", "nba", { units: "m" });
const facts = {
"court.units": court.units,
"court.bbox (m)": court.bbox.map((v) => v.toFixed(2)).join(", "),
"convertUnits(94, 'ft', 'm')": convertUnits(94, "ft", "m").toFixed(3),
"convertPoints([[47, 25]], 'ft', 'yd')": JSON.stringify(convertPoints([[47, 25]], "ft", "yd")),
"normalizeUnit('Metres')": normalizeUnit("Metres"),
"FT_PER_UNIT.m": FT_PER_UNIT.m.toFixed(4),
};
const list = Object.entries(facts)
.map(([k, v]) => `<li><code>${k}</code>: ${v}</li>`)
.join("");
`<figure style="margin:0">${toSVG(court, { width: 560, arcs: "svg" })}<figcaption><ul>${list}</ul></figcaption></figure>`;
{
"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) };
SVG
toSVG(scene, { width, height, background, precision, id }) needs no DOM: the same string comes out in Node, a worker
or a browser. arcs: "svg" writes each circle as one arc command instead of sampled points.
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
toSVG(surface("basketball", "nba"), { width: 480, background: "#f5f0e1", arcs: "svg", precision: 2 });
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;
[ "baseball little league 26 KB -> 2.5 KB", "basketball fiba 91.6 KB -> 7.1 KB", "curling curling canada 30.4 KB -> 3.4 KB", "football cfl 65.2 KB -> 65.2 KB", "hockey ahl 446.6 KB -> 32.6 KB", "lacrosse ncaam 146.5 KB -> 51.7 KB", "soccer epl 86.5 KB -> 4.8 KB", "tennis atp 2.3 KB -> 2.3 KB", "volleyball fivb 3.7 KB -> 3.7 KB" ]
import { SPORTS, leagues, surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// By default every arc is written point by point (200 per arc). `arcs: "svg"` finds the points that lie on
// one circle and writes them as a single SVG arc command; the drawing is the same. Football, tennis and
// volleyball surfaces have no arcs, so they do not change.
const kb = (s: string): number => Math.round(new TextEncoder().encode(s).length / 102.4) / 10;
SPORTS.map((sport) => {
const league = leagues(sport).find((l) => l !== "custom") ?? "";
const scene = surface(sport, league);
const sampled = kb(toSVG(scene));
const arcs = kb(toSVG(scene, { arcs: "svg" }));
return `${`${sport} ${league}`.padEnd(22)} ${String(sampled).padStart(6)} KB -> ${String(arcs).padStart(5)} KB`;
});
Observable Plot
surfaceScales fixes Plot's x and y to the scene (one unit the same length on both axes, no axes) and surfaceMark
draws the scene through them, so data in the same units lands in place. sceneToGeoJSON gives one GeoJSON polygon
per feature for any tool that reads GeoJSON, and isVisiblePolygon is the rule every renderer uses to skip invisible
features.
import * as Plot from "@observablehq/plot";
import { surface } from "@sportsdataverse/sporty";
import { surfaceMark } from "@sportsdataverse/sporty/plot";
const court = surface("tennis", "itf");
const [x0, y0, x1, y1] = court.bbox;
Plot.plot({
width: 300,
height: Math.round((300 * (y1 - y0)) / (x1 - x0)),
x: { domain: [x0, x1], axis: null },
y: { domain: [y0, y1], axis: null },
marks: surfaceMark(court),
});
import * as Plot from "@observablehq/plot";
import { surface } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";
// surfaceScales fixes x/y to the scene's bbox (feet, no axes, aspect 1); surfaceMark draws the scene through
// those scales, so data in feet lands where it belongs. Baseball's origin is home plate; these are the rule book's
// landmarks (60.5 ft to the rubber, 90 ft base paths), not observations.
const field = surface("baseball", "mlb");
const spots = [
{ x: 0, y: 60.5, what: "pitcher's rubber" },
{ x: 63.6, y: 63.6, what: "first base" },
{ x: 0, y: 127.3, what: "second base" },
];
Plot.plot({
...surfaceScales(field),
width: 560,
marks: [
...surfaceMark(field),
Plot.dot(spots, { x: "x", y: "y", r: 5, fill: "white", stroke: "black" }),
Plot.text(spots, { x: "x", y: "y", text: "what", dy: -12, fill: "white" }),
],
});
import * as Plot from "@observablehq/plot";
import { surface } from "@sportsdataverse/sporty";
import { sceneToGeoJSON, surfaceScales } from "@sportsdataverse/sporty/plot";
// sceneToGeoJSON gives one GeoJSON polygon per feature (name, fill, stroke in `properties`), for any tool that
// reads GeoJSON. Here Plot.geo draws it with no projection, so x/y are feet; colour by feature name instead.
// A sheet is long and narrow, so rotation 90 lays it across the page.
const sheet = surface("curling", "wcf", { rotation: 90 });
const geo = sceneToGeoJSON(sheet);
Plot.plot({
...surfaceScales(sheet),
width: 760,
color: { legend: true },
marks: [Plot.geo(geo, { fill: (f) => f.properties.name, stroke: "black", strokeWidth: 0.5 })],
});
{
"baseball little league": "14 of 14 polygons drawn",
"basketball fiba": "60 of 64 polygons drawn",
"curling curling canada": "30 of 30 polygons drawn",
"football cfl": "444 of 496 polygons drawn",
"hockey ahl": "63 of 63 polygons drawn",
"lacrosse ncaam": "73 of 73 polygons drawn",
"soccer epl": "35 of 35 polygons drawn",
"tennis atp": "23 of 23 polygons drawn",
"volleyball fivb": "39 of 39 polygons drawn"
}import { SPORTS, leagues, surface } from "@sportsdataverse/sporty";
import { isVisiblePolygon } from "@sportsdataverse/sporty/plot";
// A feature with a transparent fill and no stroke (or no finite points) is skipped by every renderer.
// isVisiblePolygon is that rule, for code that draws a scene itself.
Object.fromEntries(
SPORTS.map((sport) => {
const league = leagues(sport).find((l) => l !== "custom") ?? "";
const polygons = surface(sport, league).features.filter((f) => f.kind === "polygon");
const drawn = polygons.filter(isVisiblePolygon).length;
return [`${sport} ${league}`, `${drawn} of ${polygons.length} polygons drawn`];
}),
);
D3 and canvas
appendSurface(selection, scene, x, y) from @sportsdataverse/sporty/d3 draws through your scale functions.
drawScene(context, scene, { width }) from @sportsdataverse/sporty/canvas paints on any 2D context: a browser canvas, or
@napi-rs/canvas in Node.
import { surface } from "@sportsdataverse/sporty";
import { appendSurface } from "@sportsdataverse/sporty/d3";
import * as d3 from "d3";
// appendSurface draws a scene into a d3 selection through the x/y scale functions you pass,
// so the surface and your data share one coordinate system. It returns the <g> it appended.
const field = surface("soccer", "nwsl", { arcResolution: 48 }); // 48 points per arc: plenty at 720 px
const [x0, y0, x1, y1] = field.bbox;
const width = 720;
const height = Math.round((width * (y1 - y0)) / (x1 - x0));
const x = d3.scaleLinear([x0, x1], [0, width]);
const y = d3.scaleLinear([y0, y1], [height, 0]);
const svg = d3.select(document.createElement("div")).append("svg").attr("viewBox", [0, 0, width, height]);
appendSurface(svg, field, x, y);
svg.append("circle").attr("cx", x(-40)).attr("cy", y(5)).attr("r", 6).attr("fill", "#b2182b"); // 40 yd left of centre (NWSL pitches are in yards)
svg.node();
import { createCanvas } from "@napi-rs/canvas";
import { surface } from "@sportsdataverse/sporty";
import { drawScene } from "@sportsdataverse/sporty/canvas";
// drawScene paints a scene on any 2D context: a browser <canvas> (canvas.getContext("2d")) or, in Node,
// @napi-rs/canvas. Size the canvas from the scene's bbox; drawScene returns the size it drew.
const pitch = surface("soccer", "epl");
const [x0, y0, x1, y1] = pitch.bbox;
const width = 640;
const height = Math.round((width * (y1 - y0)) / (x1 - x0));
const canvas = createCanvas(width, height);
// For a sharp HiDPI canvas: a canvas dpr times larger, ctx.scale(dpr, dpr), then the same call.
const drawn = drawScene(canvas.getContext("2d"), pitch, { width });
const png = canvas.toBuffer("image/png").toString("base64");
`<img src="data:image/png;base64,${png}" width="${drawn.width}" height="${drawn.height}" alt="An EPL pitch painted by drawScene">`;
Moving data onto the surface
Feeds report positions in their own frames: tenths of a foot from the hoop, a 0-100 yardline, a pixel canvas.
toSurfaceFrame(rows, { from }) converts each row to surface coordinates with one of the built-in FRAMES, adding
surface_x and surface_y columns.
[
{
"x_legacy": -53,
"y_legacy": 285,
"team": "LAL",
"player": "LeBron James",
"surface_x": -13.25,
"surface_y": -5.3
},
{
"x_legacy": -136,
"y_legacy": 214,
"team": "DEN",
"player": "Nikola Jokić",
"surface_x": -20.35,
"surface_y": -13.6
}
]import { toSurfaceFrame } from "@sportsdataverse/sporty";
toSurfaceFrame(
[
{ x_legacy: -53, y_legacy: 285, team: "LAL", player: "LeBron James" },
{ x_legacy: -136, y_legacy: 214, team: "DEN", player: "Nikola Jokić" },
],
{ from: "nba-legacy" },
);
{
"nba-legacy": "stats.nba.com shot frame: tenths of a foot, hoop origin, x across the court (sdvplot court_coords; input columns default to x_legacy/y_legacy)",
"nba-legacy-vertical": "stats.nba.com shot frame with the hoop at the bottom: points already in a rotation-90 scene's rotated frame (do not rotate them again); x across with its sign kept, y toward half court",
"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",
"espn-football-0-100": "ESPN football: x is a 0-100 yardline -> -50..50 along x; y passes through (yards)"
}import { FRAMES } from "@sportsdataverse/sporty";
// Pass a name as `toSurfaceFrame(rows, { from: name })`. Frames move the DATA onto the surface; the surface
// options xTrans/yTrans move the SURFACE.
Object.fromEntries(Object.entries(FRAMES).map(([name, f]) => [name, f.description]));
import * as Plot from "@observablehq/plot";
import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";
// stats.nba.com shots: x_legacy/y_legacy (the frame's default columns), tenths of a foot from the hoop,
// x across the court. The frame turns them into court feet; every shot lands on the -x half.
const shots = toSurfaceFrame(NBA_SHOTS, { from: "nba-legacy" });
const court = surface("basketball", "nba", { displayRange: "defense", arcResolution: 48 });
Plot.plot({
...surfaceScales(court),
width: 640,
marks: [
...surfaceMark(court),
Plot.dot(shots, { x: "surface_x", y: "surface_y", r: 6, fill: (d) => (d.made ? "#1b7837" : "#b2182b") }),
],
});
[
{
"team": "PHI",
"period": 1,
"clock": "6:15",
"yardline": 99,
"field_x": 49,
"field_y": null
},
{
"team": "PHI",
"period": 2,
"clock": "1:35",
"yardline": 88,
"field_x": 38,
"field_y": null
},
{
"team": "PHI",
"period": 3,
"clock": "2:40",
"yardline": 54,
"field_x": 4,
"field_y": null
},
{
"team": "KC",
"period": 3,
"clock": "0:34",
"yardline": 24,
"field_x": -26,
"field_y": null
},
{
"team": "KC",
"period": 4,
"clock": "2:54",
"yardline": 7,
"field_x": -43,
"field_y": null
},
{
"team": "KC",
"period": 4,
"clock": "1:48",
"yardline": 50,
"field_x": 0,
"field_y": null
}
]import { SUPER_BOWL_LIX_TDS } from "@sportsdataverse/examples/data";
import { toSurfaceFrame } from "@sportsdataverse/sporty";
// ESPN plays: a 0-100 yardline from the home team's goal line (Super Bowl LIX's touchdowns, Philadelphia at home).
// The frame centres the field: midfield is x = 0. `x`/`y` pick the input columns and `out` names the outputs, so the
// frame fits any table. ESPN reports no lateral position, so these rows have no `y` and field_y is null, never NaN.
toSurfaceFrame(
SUPER_BOWL_LIX_TDS.map(({ team, clock, period, yardline }) => ({ team, period, clock, yardline })),
{ from: "espn-football-0-100", x: "yardline", out: { x: "field_x", y: "field_y" } },
);
{
"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`,
),
};
frameBottomLeft(length, width) handles the common feed with the origin at a corner, and a Frame of your own is
two functions and a description.
import * as Plot from "@observablehq/plot";
import { SOCCER_SPECS, frameBottomLeft, surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";
// Many feeds put (0, 0) at a corner. frameBottomLeft(length, width) recentres them on the surface.
// sporty's EPL pitch is 120 x 90 m (its spec table), so data in metres from the bottom-left corner flag:
const { pitch_length, pitch_width } = SOCCER_SPECS.epl;
const spots = toSurfaceFrame(
[
{ x: 0, y: 0, what: "corner flag" },
{ x: pitch_length / 2, y: pitch_width / 2, what: "centre spot" },
{ x: pitch_length - 11, y: pitch_width / 2, what: "penalty mark" },
],
{ from: frameBottomLeft(pitch_length, pitch_width) },
);
const pitch = surface("soccer", "epl", { arcResolution: 48 }); // 48 points per arc: plenty at 640 px
Plot.plot({
...surfaceScales(pitch),
width: 640,
marks: [
...surfaceMark(pitch),
Plot.dot(spots, { x: "surface_x", y: "surface_y", r: 5, fill: "#b2182b" }),
Plot.text(spots, { x: "surface_x", y: "surface_y", text: "what", dy: -12 }),
],
});
The same frame draws a pass map: StatsBomb's open data in socceraction's SPADL is in metres from the bottom-left
corner of a 105 × 68 m pitch, so frameBottomLeft(105, 68) moves it onto sporty's FIFA pitch drawn at 105 × 68
(updates: { pitch_length: 105, pitch_width: 68 }), and Plot.arrow draws each pass. Data: StatsBomb open data,
credited under the figure with StatsBomb's logo, as StatsBomb's terms ask.
Data: StatsBomb open dataimport * as Plot from "@observablehq/plot";
import { WC2018_FINAL_FRANCE_PASSES } from "@sportsdataverse/examples/data";
import { frameBottomLeft, surface, toSurfaceFrame } from "@sportsdataverse/sporty";
import { surfaceMark, surfaceScales } from "@sportsdataverse/sporty/plot";
// StatsBomb open data as SPADL: metres from the bottom-left corner of a 105 x 68 pitch, France attacking left to
// right. The pitch is sporty's FIFA pitch at those dimensions, so the data and the lines share one frame.
const frame = frameBottomLeft(105, 68);
const starts = toSurfaceFrame(WC2018_FINAL_FRANCE_PASSES, {
from: frame,
x: "start_x",
y: "start_y",
out: { x: "x1", y: "y1" },
});
const passes = toSurfaceFrame(starts, { from: frame, x: "end_x", y: "end_y", out: { x: "x2", y: "y2" } });
const pitch = surface("soccer", "fifa", {
updates: { pitch_length: 105, pitch_width: 68 },
arcResolution: 48,
});
Plot.plot({
...surfaceScales(pitch),
width: 760,
caption: "Data: StatsBomb open data (match 8658), as socceraction SPADL.",
marks: [
...surfaceMark(pitch),
Plot.arrow(passes, {
x1: "x1",
y1: "y1",
x2: "x2",
y2: "y2",
stroke: "#002395",
strokeWidth: 1.25,
headLength: 6,
}),
],
});
[
{
"px": 504,
"py": 0,
"surface_x": 42,
"surface_y": 0
},
{
"px": -282,
"py": 120,
"surface_x": -23.5,
"surface_y": 10
}
]import { type Frame, toSurfaceFrame } from "@sportsdataverse/sporty";
// A Frame is two functions and a description. They see a view { x, y } of the chosen input columns and
// return feet on the surface, or null. Here: a basketball feed in inches from the centre circle.
const inches: Frame = {
x: (r) => (typeof r.x === "number" ? r.x / 12 : null),
y: (r) => (typeof r.y === "number" ? r.y / 12 : null),
description: "inches from centre court",
};
toSurfaceFrame(
[
{ px: 504, py: 0 },
{ px: -282, py: 120 },
],
{ from: inches, x: "px", y: "py" },
);
Building blocks
A surface feature is one or more rings of points from the shape primitives, placed, reflected and rotated by the transforms; both are exported for drawing your own features.
import * as Plot from "@observablehq/plot";
import {
type Point,
createCircle,
createDiamond,
createRectangle,
createSquare,
createXShape,
} from "@sportsdataverse/sporty";
// Each returns a ring of [x, y] points; a surface feature is one or more of them. Angles are in units of pi.
const shapes: { name: string; ring: Point[] }[] = [
{ name: "createCircle", ring: createCircle({ r: 1.5, npoints: 40 }) },
{ name: "createCircle (half)", ring: createCircle({ center: [5, -0.75], r: 1.5, start: 0, end: 1 }) },
{ name: "createDiamond", ring: createDiamond(3, 2, [10, 0]) },
{ name: "createRectangle", ring: createRectangle(13.5, 16.5, -1, 1) },
{ name: "createSquare", ring: createSquare(2.5, [20, 0]) },
{ name: "createXShape", ring: createXShape(3, 0.6).map(([x, y]): Point => [x + 25, y]) },
];
const labels = shapes.map(({ name, ring }) => ({
name,
x: ring.reduce((s, [x]) => s + x, 0) / ring.length,
}));
Plot.plot({
width: 760,
aspectRatio: 1,
x: { axis: null },
y: { axis: null, domain: [-2.6, 2] },
marks: [
...shapes.map(({ ring }) => Plot.line(ring, { stroke: "steelblue", strokeWidth: 2 })),
Plot.text(labels, { x: "x", y: -2.3, text: "name" }),
],
});
import * as Plot from "@observablehq/plot";
import {
type Point,
createDiamond,
placeFeature,
reflectCoords,
rotateCoords,
} from "@sportsdataverse/sporty";
// A small off-centre shape, in its own frame.
const shape: Point[] = createDiamond(2, 1, [1, 0]);
const rows = [
// placeFeature anchors the shape and, with reflectX/reflectY, mirrors it into the other quadrants
// (how a court draws four corner marks from one): 4 rings.
...placeFeature(shape, { xAnchor: 6, yAnchor: 4, reflectX: true, reflectY: true }).map((ring) => ({
ring,
how: "placeFeature(…, reflectX, reflectY)",
})),
{ ring: reflectCoords(shape, { overY: true }), how: "reflectCoords(…, { overY: true })" },
{ ring: rotateCoords(shape, 90), how: "rotateCoords(…, 90)" },
{ ring: shape, how: "the shape" },
];
Plot.plot({
width: 640,
aspectRatio: 1,
inset: 10,
color: { legend: true },
marks: [
Plot.ruleX([0]),
Plot.ruleY([0]),
...rows.map(({ ring, how }) => Plot.line(ring, { stroke: () => how, strokeWidth: 2 })),
],
});
Errors
Every error is a SportyError. A league name is any string at compile time, so an unknown one throws
UnknownLeagueError when the surface is built; an unknown display range or unit has its own class.
{
"surface(\"hockey\", \"khl\")": {
"name": "UnknownLeagueError",
"instanceof SportyError": true,
"instanceof UnknownLeagueError": true,
"instanceof UnknownDisplayRangeError": false,
"instanceof UnknownUnitError": false,
"instanceof InputError": false,
"message": "Unknown hockey league \"khl\"; expected one of: ahl, custom, echl, iihf, ncaa, nhl, nwhl, ohl, phf, pwhl, qmjhl,…"
},
"surface(\"hockey\", \"nhl\", { displayRange: \"slot\" })": {
"name": "UnknownDisplayRangeError",
"instanceof SportyError": true,
"instanceof UnknownLeagueError": false,
"instanceof UnknownDisplayRangeError": true,
"instanceof UnknownUnitError": false,
"instanceof InputError": false,
"message": "Unknown hockey display range \"slot\"; expected one of: full, in_bounds_only, in bounds only, offense, offence, …"
},
"normalizeUnit(\"furlong\")": {
"name": "UnknownUnitError",
"instanceof SportyError": true,
"instanceof UnknownLeagueError": false,
"instanceof UnknownDisplayRangeError": false,
"instanceof UnknownUnitError": true,
"instanceof InputError": false,
"message": "Unknown unit \"furlong\"; expected one of: mm, cm, m, in, ft, yd (or a full name such as \"feet\")"
},
"surface(\"soccer\", \"epl\", { arcResolution: 1 })": {
"name": "SportyError",
"instanceof SportyError": true,
"instanceof UnknownLeagueError": false,
"instanceof UnknownDisplayRangeError": false,
"instanceof UnknownUnitError": false,
"instanceof InputError": false,
"message": "arcResolution must be an integer >= 2; got 1"
},
"drawScene(ctx, surface(\"soccer\", \"epl\", { xlim: [0, 0] }))": {
"name": "InputError",
"instanceof SportyError": true,
"instanceof UnknownLeagueError": false,
"instanceof UnknownDisplayRangeError": false,
"instanceof UnknownUnitError": false,
"instanceof InputError": true,
"message": "soccer \"epl\" has an empty bbox [0, -50, 0, 50]; nothing to draw"
}
}import { createCanvas } from "@napi-rs/canvas";
import {
InputError,
SportyError,
UnknownDisplayRangeError,
UnknownLeagueError,
UnknownUnitError,
normalizeUnit,
surface,
} from "@sportsdataverse/sporty";
import { drawScene } from "@sportsdataverse/sporty/canvas";
const caught = (f: () => unknown) => {
try {
f();
return "no error";
} catch (e) {
if (!(e instanceof SportyError)) throw e;
return {
name: e.name,
"instanceof SportyError": e instanceof SportyError,
"instanceof UnknownLeagueError": e instanceof UnknownLeagueError,
"instanceof UnknownDisplayRangeError": e instanceof UnknownDisplayRangeError,
"instanceof UnknownUnitError": e instanceof UnknownUnitError,
"instanceof InputError": e instanceof InputError,
message: e.message.length > 110 ? `${e.message.slice(0, 110)}…` : e.message,
};
}
};
// A league name is any string at compile time (new leagues need no release), so a typo surfaces here.
{
'surface("hockey", "khl")': caught(() => surface("hockey", "khl")),
'surface("hockey", "nhl", { displayRange: "slot" })': caught(() =>
// @ts-expect-error: the display range is checked at compile time too
surface("hockey", "nhl", { displayRange: "slot" }),
),
'normalizeUnit("furlong")': caught(() => normalizeUnit("furlong")),
'surface("soccer", "epl", { arcResolution: 1 })': caught(() =>
surface("soccer", "epl", { arcResolution: 1 }),
),
// nothing to draw: the xlim crops the pitch to zero width (here on an @napi-rs/canvas context, in Node)
'drawScene(ctx, surface("soccer", "epl", { xlim: [0, 0] }))': caught(() =>
drawScene(createCanvas(100, 100).getContext("2d"), surface("soccer", "epl", { xlim: [0, 0] })),
),
};
In team colours
sdvplot's surface(league, { team }) paints a sporty surface in a team's colours (colorUpdates maps the colours to
sporty's colour keys, SURFACES maps each sdvplot league to the sporty surface it draws) and returns Plot marks and scales; the same
scene powers sdvplot's D3 and Chart.js surfaces.
import * as Plot from "@observablehq/plot";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { surface } from "@sportsdataverse/sdvplot/plot";
await loadLeague("nhl");
// arcResolution: points per arc (default 200); 24 is plenty at 480 px and keeps the SVG small
const rink = surface("nhl", { team: "BOS", displayRange: "offense", arcResolution: 24 });
Plot.plot({ ...rink.scales, width: 480, marks: rink.marks });
import * as Plot from "@observablehq/plot";
import { loadLeague, teamColorsSync } from "@sportsdataverse/sdvplot";
import { SURFACES, SURFACE_BASE, colorUpdates, surface } from "@sportsdataverse/sdvplot/plot";
await loadLeague("nfl");
const red = teamColorsSync("nfl", "KC") ?? SURFACE_BASE.football;
const gold = teamColorsSync("nfl", "KC", { which: "secondary" }) ?? SURFACE_BASE.football;
// What `team` paints, keyed by sporty colour key: both end zones in the primary colour.
const painted = colorUpdates("football", red, gold);
// colorUpdates overrides any key on top of that: here the defensive end zone takes the secondary colour.
const field = surface("nfl", { team: "KC", colorUpdates: { defensive_endzone: gold }, centerLogo: true });
Plot.plot({
...field.scales,
width: 800,
caption: `team "KC" paints ${Object.keys(painted).join(", ")}; nfl draws sporty's ${SURFACES.nfl?.join(" ")} surface (${Object.keys(SURFACES).length} leagues have one)`,
marks: field.marks,
});
Every league
The gallery draws every league of every sport, for example the PLL lacrosse field; see every surface.
import { surface } from "@sportsdataverse/sporty";
import { toSVG } from "@sportsdataverse/sporty/svg";
// arcs "svg" writes each circle as one arc command; precision 2 keeps 0.01 of a unit. Same drawing, ~8x smaller.
toSVG(surface("lacrosse", "pll"), { width: 640, arcs: "svg", precision: 2 });
In the gallery: Surfaces, options, units, shapes, the SVG and canvas renderers, Coordinate frames and Every surface.