deck.gl
deck.gl draws large data sets with WebGL: layers of points, arcs and polygons you can pan, zoom
and tilt, on a map or in your own coordinates. @sportsdataverse/sdvplot/deckgl builds the props for three of its
layers: team logos at points, arcs in team colours, and a sporty playing surface. It never imports deck.gl (install
@deck.gl/core and @deck.gl/layers 9.4 or later beside it, optional peers), so the props can be built anywhere, a
server included; pass them to the layer, as in new IconLayer(logoIconLayer(rows, o)). The props are plain objects:
the data rows, accessor functions that read them, and the layer's own settings. surfaceLayer draws through
@sportsdataverse/sporty, which this subpath imports when it loads. It is synchronous: load the league first
(loadLeague). The charts below are drawn by deck.gl in your browser: drag to pan and scroll to zoom.
The Python package builds the same props for pydeck (sdvplot.pydeck: logo_icon_layer, pass_arc_layer and
surface_layer). The two are held to one shared fixture of real rows and the props each must build from them.
Shots on a rink
surfaceLayer(sport, league, o) is sporty's surface as PolygonLayer props: one data row per closed loop (a faceoff circle
is two rows, its half rings), in sporty's drawing order, filled in its colour (alpha included). It is in the surface's own coordinates, deck.gl's
"cartesian" coordinate system: feet for a rink and metres for a pitch, the origin at the centre. That is the frame
NHL api-web reports shots in, so they need no conversion. o goes to sporty's surface() (updates,
colorUpdates, units, ...). Text features (a football field's yard numbers) and hidden ones are left out. Draw it
in an OrthographicView with flipY: false, so y runs up as it does on the surface.
logoIconLayer(rows, { x, y, team, league, size }) is IconLayer props with one logo per row at its x and y.
The logos are deck.gl's auto-packed icons: each data row's icon names the logo's URL, and deck.gl fetches each URL
once and packs it into its own atlas, 128 px tall at the logo's aspect, centred on the point. size is the logo's
height on screen in px (default 32). A team with no logo is left out with one warning, as the other adapters do. Over
a surface, give the icons coordinateSystem: "cartesian" too (this page's drawer does it for every layer).
With JavaScript on, deck.gl draws this figure here.
{
"rink": {
"features": 85,
"coordinateSystem": "cartesian"
},
"logos": [
{
"team": "6",
"shots": 11,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/f8/f8e95d487f02c85aeb65a200f226b795e7a8de95604df49664962ad473d57666.png"
},
{
"team": "26",
"shots": 16,
"url": "https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/68/68f45d7aa38031ec83bd985058f943d54274ad890ebf112f4971388ad87d1d4f.png"
}
]
}import { NHL_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoIconLayer, surfaceLayer } from "@sportsdataverse/sdvplot/deckgl";
await loadLeague("nhl");
// NHL api-web's shot coordinates are feet from centre ice: the frame sporty draws its rink in, so no conversion. In
// this first period of game 7 (2024), Edmonton shot at the right-hand goal and Florida at the left.
const rink = surfaceLayer("hockey", "nhl");
const logos = logoIconLayer(NHL_SHOTS, { x: "x", y: "y", team: "team", league: "nhl", size: 26 });
// the view is fitted to the rink's own extent
const xs = rink.data.flatMap((d) => d.polygon.map((p) => p[0]));
const ys = rink.data.flatMap((d) => d.polygon.map((p) => p[1]));
// In the browser: new Deck({ views: new OrthographicView({ flipY: false }), layers: [new PolygonLayer(rink), ...] })
export const browser = {
lib: "deckgl",
layers: () => [
{ layer: "PolygonLayer", props: rink } as const,
{ layer: "IconLayer", props: logos } as const,
],
bounds: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)],
width: 720,
height: 360,
label:
"The 27 shots of the first period of game 7 of the 2024 Stanley Cup Final on an NHL rink, each drawn as the shooting team's logo: Edmonton's at the right-hand goal, Florida's at the left",
} as const;
// deck.gl draws only in a browser; without JavaScript the page shows what the layers hold.
const teams = [...new Set(logos.data.map((d) => d.team))];
{
rink: { features: rink.data.length, coordinateSystem: rink.coordinateSystem },
logos: teams.map((team) => {
const rows = logos.data.filter((d) => d.team === team);
return { team, shots: rows.length, url: rows[0]?.icon.url };
}),
};
Passes as arcs, readable on the grass
passArcLayer(rows, { x, y, xend, yend, team, league }) is ArcLayer props with one arc per row, from (x, y) to
(xend, yend). Its source end takes the team's primary colour and its target end the secondary (which: "secondary" swaps them), so where the two colours differ the ends tell the direction; each team keeps one colour per
end. deck.gl's arcs stand
upright, which a top-down view sees as straight lines: tilt: 90 lays them flat on the surface, and height (deck.gl's
getHeight, default 1) sets how far each curve swings; 0.3 keeps them inside the pitch.
Pass the colour the arcs are drawn on as background and every colour keeps at least 3:1 contrast against it, as in
dark mode. Each team's colour also aims to stay apart from the others' (a CIE76 ΔE of 20 or more), through sdvplot's
shared team palette (the Python package ports it, and the shared fixture holds both to every NBA, NFL, MLB and NHL
team); if no candidate is both readable and distinct, it keeps the readable colour and warns once. A team that does not
resolve takes fallback (default #808080) as given and stays out of the palette, so its contrast is not checked: pick
a fallback that reads on your background (the default grey has none on a #808080 one). A row with a missing
coordinate is left out with one warning.
One caveat: a colour that does not read on the background gives way to the team's other colour. When only one of a
team's two colours reads, both ends take it, and the arcs no longer show their direction. France's colours are a navy
and a pale mint: on sporty's dark default grass only the mint reads, so every arc would be mint end to end. Here the
pitch is painted a mid-tone green (sporty's colorUpdates) on which both read, so each pass runs from navy at the
passer to mint at the receiver; the example checks that the two ends differ. Check sourceColor against
targetColor the same way when you pick a background.
With JavaScript on, deck.gl draws this figure here.
{
"grass": "#4a7f3a",
"passes": 75,
"sourceColor": [
0,
0,
128,
255
],
"targetColor": [
200,
230,
216,
255
]
}import { WC2018_FINAL_FRANCE_PASSES } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { passArcLayer, surfaceLayer } from "@sportsdataverse/sdvplot/deckgl";
await loadLeague("soccer");
// sporty's pitch at the 105 x 68 m size SPADL uses, centred on the centre spot. The arcs sit on the grass, and France's
// colours are a navy and a pale mint: on sporty's dark default grass only the mint reads, so both ends of every arc
// would be mint and the direction lost. On a mid-tone grass both read (3:1 or better), so each pass runs navy (the
// passer) to mint (the receiver).
const grass = "#4a7f3a";
const pitch = surfaceLayer("soccer", "fifa", {
updates: { pitch_length: 105, pitch_width: 68 },
colorUpdates: { offensive_half_pitch: grass, defensive_half_pitch: grass, pitch_apron: grass },
});
// SPADL measures from the corner; the pitch is centred, so move each pass by half the pitch. France is ESPN team 478.
const passes = WC2018_FINAL_FRANCE_PASSES.map((p) => ({
team: "478",
from_x: p.start_x - 105 / 2,
from_y: p.start_y - 68 / 2,
to_x: p.end_x - 105 / 2,
to_y: p.end_y - 68 / 2,
}));
// The grass is the background: every colour keeps 3:1 against it. tilt: 90 lays each arc flat on the pitch, so the
// top-down view shows its curve, and height: 0.3 keeps the curves low, inside the pitch.
const arcs = passArcLayer(passes, {
x: "from_x",
y: "from_y",
xend: "to_x",
yend: "to_y",
team: "team",
league: "soccer",
idSystem: "team_id",
background: grass,
tilt: 90,
height: 0.3,
});
const [first] = arcs.data;
if (first === undefined || first.sourceColor.join() === first.targetColor.join())
throw new Error("the two ends of an arc share one colour: the direction would not show");
const xs = pitch.data.flatMap((d) => d.polygon.map((p) => p[0]));
const ys = pitch.data.flatMap((d) => d.polygon.map((p) => p[1]));
// In the browser: new Deck({ views: new OrthographicView({ flipY: false }), layers: [new PolygonLayer(pitch), ...] })
export const browser = {
lib: "deckgl",
layers: () => [
{ layer: "PolygonLayer", props: pitch } as const,
{ layer: "ArcLayer", props: arcs } as const,
],
bounds: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)],
width: 720,
height: 470,
label:
"France's 75 completed first-half passes in the 2018 World Cup final as arcs from passer to receiver, attacking left to right, each running from France's navy at the passer to its mint at the receiver on a mid-green pitch",
} as const;
// deck.gl draws only in a browser; without JavaScript the page shows what the layers hold.
{
grass,
passes: arcs.data.length,
sourceColor: arcs.data[0]?.sourceColor,
targetColor: arcs.data[0]?.targetColor,
};
On a map
Without a surface, x and y are a longitude and a latitude: logoIconLayer and passArcLayer work on deck.gl's
default map view as they are, with their coordinate system left at deck.gl's default.
Reading back what was drawn
There is no drawnMarks here: the props are the record. props.data holds each logo's position, icon (with its
url) and resolved team id, and each arc's source, target and [r, g, b, a] colours, with the input row's
index, so a test can check them without a WebGL context.