MapLibre GL JS
MapLibre GL JS draws interactive vector maps in the browser with WebGL.
A map's look is a style, a JSON document of sources (where the data comes from) and layers (how it is drawn), and
your own data joins it the same way: a GeoJSON source and a layer that reads it. @sportsdataverse/sdvplot/maplibre
builds that JSON for teams: logos at their venues as a native symbol layer, team-coloured travel lines as a line
layer, and a free basemap that needs no API key. It never imports MapLibre (install maplibre-gl 6.14.0 or later
beside it, an optional peer, BSD-3-Clause); you pass your map in where one is needed. Every piece but the image
loader can be built anywhere, a server included. It is synchronous: load the league first (loadLeague). The maps
below are drawn by MapLibre in your browser.
Team logos at their venues
logoSymbols(rows, o) reads each row's longitude, latitude and team (x, y and team name the columns) and
returns { id, source, layer, images }. The source is GeoJSON with one point per row, whose feature id is the row's
index and whose properties are the resolved team, the logo's URL and the map image it draws. The layer is a symbol
layer whose icon-image reads that property; every logo is shown, even where two overlap. size is the logo's height
on screen in pixels (32 by default) and alpha its opacity. Team values can be abbreviations, names or ids, and
numbers as well as strings: a column of numeric ESPN ids draws the same logos as the abbreviations. Longitudes and
latitudes may be numbers or numeric strings, as a CSV reads them (routeLines reads them the same way).
A symbol layer draws images the map already holds, so the logos go in first. addLogoImages(map, logos) loads each
distinct logo once through the map (loadImage), adds it (addImage) at the pixel ratio that draws it size pixels
tall (sharp on a high-density screen too), and skips any image the map already has, so calling it twice adds
nothing twice. Await it, then add the source and the layer (map.addSource(logos.id, logos.source) and
map.addLayer(logos.layer)), all after the map's load event.
Switching the basemap with map.setStyle (light to dark, say) drops every image, source and layer you added. Listen
for the map's style.load event, which fires each time a style finishes loading, and in it do the same three steps
again: await addLogoImages, then add the source and the layer. Because it skips images the map already has, the
repeat call is safe whether the new style kept them or not, and it loads only what is missing. routeLines needs the
same: re-add its source and layer on style.load.
A team with no archived logo, or one that does not resolve, is left out with one warning, and so is a row with a
missing, non-numeric or infinite coordinate; a logo that fails to load is left undrawn with one warning. Each team's 2024 home stadium below
comes from nfelo's stadium table (greerreNFL/Stadiums, with thanks;
copied into sportsdataverse/nfl-data), joined to the season's games by nflverse's stadium_id. Two pairs of teams share a stadium, so one logo of each pair sits on the
other.
With JavaScript on, MapLibre GL JS draws this figure here.
{
"logos": 32,
"images": 32,
"first": [
[
"22",
-112.2625948,
33.5277555,
34,
"https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/f8/f870b58b43585a5b7717d578dd433b0b1e0c462d64ef4bea2aeb8c03f4ae7854.png"
],
[
"1",
-84.4008484,
33.7555052,
34,
"https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/fe/fe566e9da6f7986b2bac321571bd751247ff93af15c51ae555486fb1bee90c91.png"
],
[
"33",
-76.6227221,
39.277958,
34,
"https://sdv.nyc3.cdn.digitaloceanspaces.com/assets/public/sha256/67/67796023c172c9aeab5bcdac9204823d3590097246ad1cb92971134b5f26dcc0.png"
]
]
}import { NFL_STADIUMS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { addLogoImages, basemap, drawnMarks, logoSymbols } from "@sportsdataverse/sdvplot/maplibre";
await loadLeague("nfl");
// Each team's 2024 home stadium, joined by nflverse stadium_id to nfelo's stadium table (via nfl-data). The Rams and
// the Chargers share SoFi Stadium, and the Giants and the Jets MetLife Stadium, so one logo of each pair sits on the
// other.
const logos = logoSymbols(NFL_STADIUMS_2024, { x: "lon", y: "lat", team: "team", league: "nfl", size: 34 });
const { style } = basemap("light");
// In the browser this page calls mount with maplibre-gl's module and the map's element.
export const browser = {
lib: "maplibre",
mount: async ({ maplibregl }: { maplibregl: typeof import("maplibre-gl") }, el: HTMLElement) => {
const map = new maplibregl.Map({
container: el,
style, // OpenFreeMap's credit shows in the map's attribution control, from the style's own source
bounds: [
[-124, 25],
[-70, 48],
],
fitBoundsOptions: { padding: 24 },
});
await map.once("load");
// the logos as map images first (each fetched once), then the layer that draws them
await addLogoImages(map, logos);
map.addSource(logos.id, logos.source);
map.addLayer(logos.layer);
return { map };
},
width: 720,
height: 440,
label: "Map of the United States with each NFL team's logo at its 2024 home stadium",
} as const;
// MapLibre draws only in a browser (and the basemap's tiles come from OpenFreeMap); without JavaScript the page shows
// what the layer holds: each logo, its stadium and coordinates.
{
logos: logos.source.data.features.length,
images: logos.images.length,
first: drawnMarks(logos).slice(0, 3),
};
Team-coloured travel
routeLines(rows, o) draws one straight line per row, from (x, y) to (xend, yend) in longitude and
latitude, in the team's colour: { id, source, layer }, a GeoJSON source of two-point lines (the feature id is the
row's index; the properties are the team and its colour) and a line layer whose line-color reads the colour.
Each team keeps one colour, its primary unless which says secondary. width is in pixels (3 by default) and
alpha sets the opacity. A team that does not resolve takes fallback (grey by default) and a row with a missing
coordinate is left out with one warning. Add the lines before the logos, so the logos sit on top.
On a dark basemap, pass its background as background: every team colour then keeps 3:1 contrast against it and
stays apart from the other teams' colours, through the palette every sdvplot adapter shares. The contrast is measured
against the basemap's land colour, its background layer, not its water, which on OpenFreeMap's dark style is a
little lighter and on its light style a little darker. At low zoom the dark style also lays a shaded-relief raster
(ne2_shaded) over the land, so land there is not exactly #0c0c0c either. The guarantee covers teams that resolve: a
team that does not takes fallback as given, outside the palette. The default grey, #808080, keeps 3:1 on both
OpenFreeMap basemaps; if you set your own fallback, pick one that contrasts with your background.
The road trips below fly airport to airport: each team's home airport comes from nflverse's
nfldata airports.csv. The same rows feed passArcLayer for arcs instead
(next section).
With JavaScript on, MapLibre GL JS draws this figure here.
[
{
"team": "21",
"week": 3,
"to": "NO",
"color": "#004c54"
},
{
"team": "12",
"week": 3,
"to": "ATL",
"color": "#e31837"
},
{
"team": "21",
"week": 4,
"to": "TB",
"color": "#004c54"
},
{
"team": "12",
"week": 4,
"to": "LAC",
"color": "#e31837"
},
{
"team": "21",
"week": 7,
"to": "NYG",
"color": "#004c54"
},
{
"team": "12",
"week": 7,
"to": "SF",
"color": "#e31837"
},
{
"team": "21",
"week": 8,
"to": "CIN",
"color": "#004c54"
},
{
"team": "12",
"week": 8,
"to": "LV",
"color": "#e31837"
},
{
"team": "21",
"week": 10,
"to": "DAL",
"color": "#004c54"
},
{
"team": "12",
"week": 11,
"to": "BUF",
"color": "#e31837"
},
{
"team": "12",
"week": 12,
"to": "CAR",
"color": "#e31837"
},
{
"team": "21",
"week": 12,
"to": "LA",
"color": "#004c54"
},
{
"team": "21",
"week": 13,
"to": "BAL",
"color": "#004c54"
},
{
"team": "12",
"week": 15,
"to": "CLE",
"color": "#e31837"
},
{
"team": "21",
"week": 16,
"to": "WAS",
"color": "#004c54"
},
{
"team": "12",
"week": 17,
"to": "PIT",
"color": "#e31837"
},
{
"team": "12",
"week": 18,
"to": "DEN",
"color": "#e31837"
}
]import { KC_PHI_GAMES_2024, NFL_AIRPORTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { addLogoImages, basemap, logoSymbols, routeLines } from "@sportsdataverse/sdvplot/maplibre";
await loadLeague("nfl");
const airport = (team: string) => {
const a = NFL_AIRPORTS.find((x) => x.team === team);
if (a === undefined) throw new Error(`no airport for ${team}`);
return a;
};
// Every 2024 regular-season road game of the two Super Bowl LIX teams, flown from the team's home airport to its
// host's (nflverse nfldata's airports). The Eagles' week 1 game in São Paulo was their home game on the schedule, so
// it is not a road trip here.
const legs = KC_PHI_GAMES_2024.filter((g) => g.away_team === "KC" || g.away_team === "PHI").map((g) => {
const [from, to] = [airport(g.away_team), airport(g.home_team)];
return {
team: g.away_team,
week: g.week,
host: g.home_team,
lon: from.lon,
lat: from.lat,
lon_to: to.lon,
lat_to: to.lat,
};
});
const routes = routeLines(legs, {
x: "lon",
y: "lat",
xend: "lon_to",
yend: "lat_to",
team: "team",
league: "nfl",
width: 3,
});
// each team's logo at its home airport, where every trip starts
const homes = logoSymbols([airport("KC"), airport("PHI")], {
x: "lon",
y: "lat",
team: "team",
league: "nfl",
size: 40,
});
const { style } = basemap("light");
// In the browser this page calls mount with maplibre-gl's module and the map's element.
export const browser = {
lib: "maplibre",
mount: async ({ maplibregl }: { maplibregl: typeof import("maplibre-gl") }, el: HTMLElement) => {
const map = new maplibregl.Map({
container: el,
style,
bounds: [
[-123, 30],
[-72, 44],
],
fitBoundsOptions: { padding: 32 },
});
await map.once("load");
await addLogoImages(map, homes);
// the lines first, so the logos sit on top of the trips they start
map.addSource(routes.id, routes.source);
map.addLayer(routes.layer);
map.addSource(homes.id, homes.source);
map.addLayer(homes.layer);
return { map };
},
width: 720,
height: 420,
label:
"Map of the United States with the Kansas City Chiefs' 2024 road trips as red lines from Kansas City and the Philadelphia Eagles' as green lines from Philadelphia, each team's logo at its home airport",
} as const;
// Without JavaScript the page shows each flight and its colour.
routes.source.data.features.map((f) => ({
team: f.properties.team,
week: legs[f.id]?.week,
to: legs[f.id]?.host,
color: f.properties.color,
}));
deck.gl layers on a MapLibre map
deck.gl draws over a MapLibre map through @deck.gl/mapbox's MapboxOverlay (9.4.0 or later, an optional peer; the
name is historical, it works with MapLibre). Build its layers with @sportsdataverse/sdvplot/deckgl, exactly as
for deck.gl alone, in longitude and latitude (deck.gl's default coordinates): passArcLayer gives arcs in team
colours, primary at the source and secondary at the target, and logoIconLayer gives logos. Construct deck.gl's
ArcLayer and IconLayer from those props, hand them to a MapboxOverlay, and add the overlay to the map as a
control (map.addControl). Pass the basemap's background to passArcLayer as background for dark mode, as for
routeLines. See deck.gl for the layers themselves.
With JavaScript on, MapLibre GL JS draws this figure here.
{
"background": "#0c0c0c",
"trips": 17,
"colors": {
"12": {
"home": [
227,
24,
55,
255
],
"host": [
255,
182,
18,
255
]
},
"21": {
"home": [
165,
172,
175,
255
],
"host": [
165,
172,
175,
255
]
}
}
}import { KC_PHI_GAMES_2024, NFL_AIRPORTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { logoIconLayer, passArcLayer } from "@sportsdataverse/sdvplot/deckgl";
import { basemap } from "@sportsdataverse/sdvplot/maplibre";
await loadLeague("nfl");
const airport = (team: string) => {
const a = NFL_AIRPORTS.find((x) => x.team === team);
if (a === undefined) throw new Error(`no airport for ${team}`);
return a;
};
// Each 2024 road game of the two Super Bowl LIX teams, home airport to the host's (nflverse nfldata's airports)
const trips = KC_PHI_GAMES_2024.filter((g) => g.away_team === "KC" || g.away_team === "PHI").map((g) => {
const [from, to] = [airport(g.away_team), airport(g.home_team)];
return { team: g.away_team, lon: from.lon, lat: from.lat, lon_to: to.lon, lat_to: to.lat };
});
// The dark basemap's background is what the arc colours must read on (3:1). Each arc runs from the team's primary
// colour at home to its secondary at the host: the Chiefs' red to gold. The Eagles' midnight green does not read on
// it, so both ends of their arcs take the colour that does, their silver.
const { style, background } = basemap("dark");
const arcs = passArcLayer(trips, {
x: "lon",
y: "lat",
xend: "lon_to",
yend: "lat_to",
team: "team",
league: "nfl",
background,
width: 3,
});
const homes = logoIconLayer([airport("KC"), airport("PHI")], {
x: "lon",
y: "lat",
team: "team",
league: "nfl",
variant: "dark",
size: 40,
});
// In the browser this page calls mount with maplibre-gl's module, deck.gl's loader and the map's element.
export const browser = {
lib: "maplibre",
mount: async (
{
maplibregl,
deck,
}: {
maplibregl: typeof import("maplibre-gl");
// deck.gl's MapLibre overlay and the layers, loaded by the page
deck: () => Promise<{
MapboxOverlay: typeof import("@deck.gl/mapbox").MapboxOverlay;
ArcLayer: typeof import("@deck.gl/layers").ArcLayer;
IconLayer: typeof import("@deck.gl/layers").IconLayer;
}>;
},
el: HTMLElement,
) => {
const { MapboxOverlay, ArcLayer, IconLayer } = await deck();
const map = new maplibregl.Map({
container: el,
style,
bounds: [
[-123, 30],
[-72, 44],
],
fitBoundsOptions: { padding: 32 },
});
await map.once("load");
// deck.gl draws over the map, in its longitude and latitude, as a MapLibre control
const layers = [new ArcLayer(arcs), new IconLayer(homes)];
const overlay = new MapboxOverlay({ layers });
map.addControl(overlay);
return { map, overlay, layers };
},
width: 720,
height: 420,
label:
"Dark map of the United States with the Kansas City Chiefs' and the Philadelphia Eagles' 2024 road trips as arcs in each team's colours, from its home airport to its host's, each team's logo at home",
} as const;
// Without JavaScript the page shows each team's arc colours and the number of trips.
{
background,
trips: arcs.data.length,
colors: Object.fromEntries(arcs.data.map((a) => [a.team, { home: a.sourceColor, host: a.targetColor }])),
};
A free basemap: OpenFreeMap
basemap("light") or basemap("dark") returns { style, attribution, background }. The style is the URL of
OpenFreeMap's positron (light) or dark vector style, built from OpenStreetMap data;
pass it as the map's style. The background is the colour the style paints its land in (#f2f3f0 and #0c0c0c),
for routeLines and passArcLayer. The attribution is OpenFreeMap's credit, as HTML.
No API key, token or account is involved anywhere. OpenFreeMap's terms, as its site states them: the public instance
is free, with no limit on map views or requests, no registration, no API keys and no cookies, and it is funded by
donations. Attribution is required. MapLibre shows the credit by itself in its attribution control, which is on by
default: keep it. Anything else that draws these tiles, or a static image exported from the map, must show the
attribution text itself. Heavy users are asked to keep parallel requests modest, to send a recognisable
User-Agent, and to contact OpenFreeMap before anything larger. There is no service-level agreement; for a guarantee,
host the tiles yourself from OpenFreeMap's weekly planet downloads. Read the current terms, and the
Terms of Service linked there, on openfreemap.org before you ship.
The map data is © OpenStreetMap contributors, under the Open Database License.
Every view of a map requests the provider's tiles. These docs request none while they are built: the maps are drawn in your browser when you scroll to them, and the tests answer every OpenFreeMap request with a local stand-in style. Do the same in your own tests and server renders.
Server rendering
The module imports no MapLibre code, so logoSymbols, routeLines, basemap and drawnMarks run in Node, in a
server component or a build step, and the JSON they return can be sent to the page. Only addLogoImages needs a map,
and so a browser. MapLibre itself needs WebGL: create the map in an effect, or behind a client-only boundary.
MapLibre 6 parses tiles in a web worker that it loads from beside its own module file. A bundler that moves that
module (webpack, as in these docs) leaves the map waiting for a worker that never starts: no error, and no load
event. Hand MapLibre the worker's URL before the first map is created, with setWorkerUrl, pointing at the
maplibre-gl/dist/maplibre-gl-worker.mjs file your bundler emits (in webpack 5, a new URL of that path relative to
import.meta.url), served as JavaScript.