Skip to main content

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.