New in MapLibre GL JS 6.15
Maps in any projection
MapLibre GL JS 6.15 draws maps in your own coordinate system: polar maps, national grids, equal-area world maps, even a flat image. Every map here is live, with its code.
Maps in their own grid
Tiles that NASA and Eurostat already make in their own projection, around the poles and across Europe, plus a scanned map on a flat plane.
Loading the map…
EPSG:3413 · Polar stereographic north
The Arctic, pole in the middle
NASA's Blue Marble from a WMS that renders in the polar grid. Each tile asks for its box in the grid's meters through the {bbox} token. The places and the lines of latitude are plain lng/lat GeoJSON, projected in MapLibre's workers.
- WMS with {bbox}
- GeoJSON in the workers
- Labels
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3413 } from "crs-defs";
// NASA GIBS speaks WMS in EPSG:3413: {bbox} becomes each tile's bounds in its meters.
const gibs = (layer: string) =>
`https://gibs.earthdata.nasa.gov/wms/epsg3413/best/wms.cgi?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&SRS=EPSG:3413&STYLES=&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png&TRANSPARENT=TRUE&LAYERS=${layer}`;
const places: [string, number, number][] = [
["North Pole", 0, 90],
["Longyearbyen", 15.63, 78.22],
["Nuuk", -51.72, 64.18],
["Alert", -62.35, 82.5],
["Resolute", -94.83, 74.7],
["Murmansk", 33.08, 68.97],
["Utqiaġvik", -156.79, 71.29],
["Reykjavík", -21.94, 64.15],
];
/** Lines of latitude and longitude as GeoJSON, with a point every degree so they curve with the projection. */
function graticule(step: number, [south, north]: [number, number]): GeoJSON.FeatureCollection {
const features: GeoJSON.Feature[] = [];
for (let lat = Math.ceil(south / step) * step; lat <= north; lat += step) {
const coordinates = Array.from({ length: 361 }, (_, i) => [i - 180, lat]);
features.push({
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates },
});
}
for (let lng = -180; lng < 180; lng += step) {
const coordinates = Array.from({ length: north - south + 1 }, (_, i) => [lng, south + i]);
features.push({
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates },
});
}
return { type: "FeatureCollection", features };
}
// NSIDC Sea Ice Polar Stereographic North: the North Pole in the middle,
// Greenland upright, no Mercator stretch.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3413",
// The same string goes to proj4 in the workers, which project the GeoJSON below.
definition: EPSG_3413,
// forward([lng, lat]) gives [x, y] in meters, inverse goes back.
projection: proj4(EPSG_4326, EPSG_3413),
// The square tile grid GIBS serves EPSG:3413 in: top-left corner and width, in meters.
tileMatrixSet: { origin: [-4194304, 4194304], extentAtZoom0: 8388608 },
});
// GeoJSON is cut into tiles in MapLibre's web workers, so they need proj4 too: projection-handler.mjs,
// below, builds the same converter there from the CRS's definition. Load it once, before the first map.
const workersReady = maplibregl.importScriptInWorkers(
new URL("/projection-handler.mjs", location.href).href,
);
// Everything north of 66°, the Arctic Circle.
const bounds: maplibregl.LngLatBoundsLike = [
[-180, 66],
[180, 90],
];
export async function createMap(container: string | HTMLElement) {
await workersReady;
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3413" },
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
"blue-marble": {
type: "raster",
tiles: [gibs("BlueMarble_ShadedRelief_Bathymetry")],
tileSize: 512,
maxzoom: 6,
attribution: 'Imagery: <a href="https://earthdata.nasa.gov/gibs">NASA GIBS</a>',
},
// Plain lng/lat GeoJSON, as on any other map.
graticule: { type: "geojson", data: graticule(10, [30, 80]) },
places: {
type: "geojson",
data: {
type: "FeatureCollection",
features: places.map(([name, lng, lat]) => ({
type: "Feature",
properties: { name },
geometry: { type: "Point", coordinates: [lng, lat] },
})),
},
},
},
layers: [
{ id: "blue-marble", type: "raster", source: "blue-marble" },
{
id: "graticule",
type: "line",
source: "graticule",
paint: { "line-color": "#fff", "line-opacity": 0.35, "line-width": 1 },
},
{
id: "places",
type: "circle",
source: "places",
paint: {
"circle-radius": 5,
"circle-color": "#ff7a45",
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
},
{
id: "place-names",
type: "symbol",
source: "places",
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Bold"],
"text-size": 13,
"text-offset": [0, 1.2],
"text-anchor": "top",
},
paint: {
"text-color": "#fff",
"text-halo-color": "rgba(0, 20, 40, 0.8)",
"text-halo-width": 1.5,
},
},
],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}projection-handler.mjs, a file next to the page that the code above loads into MapLibre's web workers with maplibregl.importScriptInWorkers. They cut GeoJSON into tiles, so they need proj4 too: it builds the converter there from the CRS's definition.
// Runs in MapLibre's web workers, which cut GeoJSON into tiles: it turns a CRS's
// `definition` into the same proj4 converter the page uses. Keep proj4's version in step with the page's.
import proj4 from "https://cdn.jsdelivr.net/npm/proj4@2.22.0/+esm";
self.setProjectionHandler((definition) => proj4(definition));EPSG:3031 · Polar stereographic south
Antarctica, whole
On a Mercator map Antarctica is a smeared band along the bottom edge. In its own polar grid it is a continent again, with markers on the research stations.
- WMS with {bbox}
- Markers and popups
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3031 } from "crs-defs";
const gibs = (layer: string) =>
`https://gibs.earthdata.nasa.gov/wms/epsg3031/best/wms.cgi?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&SRS=EPSG:3031&STYLES=&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png&TRANSPARENT=TRUE&LAYERS=${layer}`;
const stations: [string, number, number][] = [
["Amundsen–Scott (USA)", 139.27, -89.99],
["McMurdo (USA)", 166.67, -77.85],
["Concordia (France, Italy)", 123.33, -75.1],
["Vostok (Russia)", 106.84, -78.46],
["Halley VI (UK)", -26.18, -75.58],
["Neumayer III (Germany)", -8.27, -70.67],
["Rothera (UK)", -68.13, -67.57],
["Davis (Australia)", 77.97, -68.58],
];
// Antarctic Polar Stereographic: the South Pole in the middle, the continent whole.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3031",
projection: proj4(EPSG_4326, EPSG_3031),
tileMatrixSet: { origin: [-4194304, 4194304], extentAtZoom0: 8388608 },
});
// Everything south of 64°.
const bounds: maplibregl.LngLatBoundsLike = [
[-180, -90],
[180, -64],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3031" },
sources: {
"blue-marble": {
type: "raster",
tiles: [gibs("BlueMarble_ShadedRelief_Bathymetry")],
tileSize: 512,
maxzoom: 6,
attribution: 'Imagery: <a href="https://earthdata.nasa.gov/gibs">NASA GIBS</a>',
},
},
layers: [{ id: "blue-marble", type: "raster", source: "blue-marble" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
// Markers and popups take lng/lat as usual; the map converts them through the CRS.
for (const [name, lng, lat] of stations) {
new maplibregl.Marker({ color: "#e8590c", scale: 0.7 })
.setLngLat([lng, lat])
.setPopup(new maplibregl.Popup({ offset: 20 }).setText(name))
.addTo(map);
}
return map;
}EPSG:3035 · Lambert azimuthal equal-area
Europe at true size
The equal-area projection the EU uses for its statistics, so the north is no longer inflated. Eurostat serves an OpenStreetMap background map in it. A small tile protocol starts the grid at Eurostat's level 2 and clears the white around its map.
- WMTS tiles
- A custom tile protocol
- A grid that starts at level 2
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3035 } from "crs-defs";
// Eurostat's EPSG3035 tile matrix set, from its WMTS capabilities, borrows Web Mercator's sizes:
// level 0 is a single 256 px tile 40,075 km wide, with its top-left corner at easting 0, northing
// 6,000,000. Europe fills only its top-left corner. Level 2's first tile, a quarter as wide, still
// holds all of Eurostat's map, so that tile is this map's grid, and map zoom z is Eurostat's level z + 2.
const LEVELS_ABOVE = 2;
const levelZeroWidth = 256 * 156543.03392804;
// The tiles come through a small protocol that asks for Eurostat's level, and makes the
// near-white Eurostat paints outside its map transparent, so the map ends where its data does.
maplibregl.addProtocol("gisco", async ({ url }, { signal }) => {
const [z, x, y] = url.replace("gisco://", "").split("/").map(Number);
const response = await fetch(
`https://gisco-services.ec.europa.eu/maps/tiles/OSMBlossomComposite/EPSG3035/${z + LEVELS_ABOVE}/${x}/${y}.png`,
{ signal },
);
// Past the edge of its map Eurostat has no tiles, which MapLibre skips quietly as a 404.
if (!response.ok)
throw Object.assign(new Error(`Tile ${url}: ${response.status}`), {
status: response.status,
});
const image = await createImageBitmap(await response.blob());
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, image.width, image.height);
for (let i = 0; i < pixels.data.length; i += 4) {
if (pixels.data[i] >= 253 && pixels.data[i + 1] >= 253 && pixels.data[i + 2] >= 253)
pixels.data[i + 3] = 0;
}
context.putImageData(pixels, 0, 0);
return { data: canvas.transferToImageBitmap() };
});
// ETRS89 Lambert Azimuthal Equal-Area, the projection the EU uses for statistics:
// every country at its true size relative to the others.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3035",
projection: proj4(EPSG_4326, EPSG_3035),
tileMatrixSet: { origin: [0, 6000000], extentAtZoom0: levelZeroWidth / 2 ** LEVELS_ABOVE },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-15, 34],
[37, 70],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3035" },
sources: {
gisco: {
type: "raster",
// Eurostat's GISCO background map: OpenStreetMap, drawn in EPSG:3035.
tiles: ["gisco://{z}/{x}/{y}"],
tileSize: 256,
maxzoom: 18 - LEVELS_ABOVE,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>, <a href="https://ec.europa.eu/eurostat/web/gisco">EC-GISCO</a>, © EuroGeographics for the administrative boundaries',
},
},
layers: [{ id: "gisco", type: "raster", source: "gisco" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}identity · A flat plane
Not a projection at all
The built-in identity projection takes coordinates as they are, for floor plans, game maps and scans. Here it shows Olaus Magnus' Carta Marina of 1539, sea monsters and all.
- The identity projection
- An image source
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
// Olaus Magnus' Carta Marina of 1539, a scan from Wikimedia Commons (public domain).
const image =
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Carta_Marina.jpeg/3840px-Carta_Marina.jpeg";
const [width, height] = [3840, 2844];
// The built-in identity projection is a flat plane: x and y go in as "lng" and "lat",
// from -90 to 90, with no stretch. Spread the picture's pixels over the plane's width.
const scale = 180 / width;
const fromPixel = (x: number, y: number): [number, number] => [
x * scale - 90,
(height / 2 - y) * scale,
];
const sights: [string, number, number][] = [
["Sea serpent", 1404, 852],
["Maelstrom", 1666, 588],
["Giant lobster", 264, 1680],
["Physeter, a whale", 264, 1056],
["Iceland", 720, 384],
["Scandinavia", 2040, 1308],
];
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [fromPixel(0, height), fromPixel(width, 0)];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "identity" },
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
map: {
type: "image",
url: image,
// The four corners, clockwise from the top left.
coordinates: [
fromPixel(0, 0),
fromPixel(width, 0),
fromPixel(width, height),
fromPixel(0, height),
],
},
sights: {
type: "geojson",
attribution:
'<a href="https://commons.wikimedia.org/wiki/File:Carta_Marina.jpeg">Carta Marina</a>, Olaus Magnus, 1539',
data: {
type: "FeatureCollection",
features: sights.map(([name, x, y]) => ({
type: "Feature",
properties: { name },
geometry: { type: "Point", coordinates: fromPixel(x, y) },
})),
},
},
},
layers: [
{ id: "background", type: "background", paint: { "background-color": "#2b2a26" } },
{ id: "map", type: "raster", source: "map" },
{
id: "sight-dots",
type: "circle",
source: "sights",
paint: {
"circle-radius": 4,
"circle-color": "#9c2a12",
"circle-stroke-width": 2,
"circle-stroke-color": "#fff4d6",
},
},
{
id: "sights",
type: "symbol",
source: "sights",
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Bold"],
"text-size": 14,
"text-anchor": "left",
"text-offset": [0.8, 0],
},
paint: {
"text-color": "#3b1d0a",
"text-halo-color": "rgba(255, 244, 214, 0.9)",
"text-halo-width": 2,
},
},
],
},
maxZoom: 5,
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}Countries in their national grid
National mapping agencies draw their maps in the grid their country is measured in. Where an agency publishes ready-made tiles (WMTS) the map uses them, as they load fastest; elsewhere its WMS draws each tile on request, through the {bbox} token. This page keeps the WMS tiles it shows in a cache of its own, so they come in at once; the code here asks the agencies directly.
Loading the map…
EPSG:3577 · GDA94 / Australian Albers
Australia, equal area
Geoscience Australia's base map, drawn by its ArcGIS server in Australian Albers, the equal-area projection for maps of the whole continent. {bbox} puts each tile's box in the export URL.
- ArcGIS export with {bbox}
- Equal area
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3577 } from "crs-defs";
// GDA94 / Australian Albers, an equal-area projection for the whole continent.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3577",
projection: proj4(EPSG_4326, EPSG_3577),
// A square around Australia, 9,120 km wide. The server draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-4416000, 1539000], extentAtZoom0: 9120000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[112.9, -43.7],
[153.7, -10.6],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3577" },
sources: {
map: {
type: "raster",
// The export URL draws any box in any projection: {bbox} becomes each tile's box.
tiles: [
"https://services.ga.gov.au/gis/rest/services/NationalBaseMap/MapServer/export?bbox={bbox}&bboxSR=3577&imageSR=3577&size=512,512&format=png&f=image",
],
tileSize: 512,
maxzoom: 14,
attribution: '© <a href="https://www.ga.gov.au/">Geoscience Australia</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:31370 · Belgian Lambert 72
Belgium in Lambert 72
The National Geographic Institute's topographic map from its cartoweb WMS, in Belgian Lambert 72, the country's national grid.
- WMS with {bbox}
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_31370 } from "crs-defs";
// Belgian Lambert 72, Belgium's national grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:31370",
projection: proj4(EPSG_4326, EPSG_31370),
// A square around Belgium, 570 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-127000, 419000], extentAtZoom0: 570000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[2.54, 49.5],
[6.41, 51.51],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:31370" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://cartoweb.wms.ngi.be/service?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=topo&STYLES=&SRS=EPSG:31370&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 10,
attribution: '© <a href="https://www.ngi.be/">NGI-IGN</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:3978 · NAD83 / Canada Atlas Lambert
Canada in Lambert conformal conic
Natural Resources Canada's base map, drawn by its ArcGIS server in the Lambert projection of the Atlas of Canada. On a Mercator map the Arctic islands would look bigger than the provinces. Tick North up and the map turns as you pan, so the meridian through the center always points up.
- ArcGIS export with {bbox}
- Conformal conic
- transformCameraUpdate
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3978 } from "crs-defs";
// NAD83 / Canada Atlas Lambert, the projection of the Atlas of Canada.
const projection = proj4(EPSG_4326, EPSG_3978);
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3978",
projection,
// A square around Canada, 14,030 km wide. The export URL draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-7134000, 8592000], extentAtZoom0: 14030000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-141, 41.7],
[-52.6, 83.1],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3978" },
sources: {
map: {
type: "raster",
// NRCan's ArcGIS server draws any box from its base map: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://maps-cartes.services.geo.ca/server2_serveur2/rest/services/BaseMaps/CBMT3978/MapServer/export?bbox={bbox}&bboxSR=3978&imageSR=3978&size=512,512&format=png&f=image",
],
tileSize: 512,
maxzoom: 15,
attribution:
'© <a href="https://natural-resources.canada.ca/">Natural Resources Canada</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
// A North up checkbox. Leave it out for a map with the grid's own up.
// The meridians meet at the North Pole, so away from 95°W true north leans off the grid's up,
// by about 0.9° per degree of longitude. This is the bearing that turns it back up.
function trueNorthBearing({ lng, lat }: { lng: number; lat: number }) {
const [x0, y0] = projection.forward([lng, lat - 0.01]);
const [x1, y1] = projection.forward([lng, lat]);
return (Math.atan2(x1 - x0, y1 - y0) * 180) / Math.PI;
}
// MapLibre's controls stay white on a dark page too, so the label sets dark text and a light checkbox.
const label = document.createElement("label");
label.className = "maplibregl-ctrl maplibregl-ctrl-group";
label.style.cssText =
"display: flex; gap: 6px; align-items: center; padding: 4px 8px; font: 12px sans-serif; color: #333; color-scheme: light; cursor: pointer";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
label.append(checkbox, "North up");
map.addControl({ onAdd: () => label, onRemove: () => label.remove() }, "top-left");
checkbox.addEventListener("change", () => {
// Turn the map, then turn it again on every camera move: pans, zooms and flights.
map.setTransformCameraUpdate(null);
map.once("moveend", () => {
if (checkbox.checked) {
map.setTransformCameraUpdate(({ center }) => ({ bearing: trueNorthBearing(center) }));
}
});
map.easeTo({ bearing: checkbox.checked ? trueNorthBearing(map.getCenter()) : 0 });
});
return map;
}EPSG:5514 · S-JTSK / Krovak East North
Czechia in the Krovak projection
ČÚZK's base map from its WMS, in the oblique conic projection Josef Křovák designed for Czechoslovakia in 1922. proj4 converts it like any other.
- WMS with {bbox}
- An oblique conic
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_5514 } from "crs-defs";
// S-JTSK / Krovak East North, the oblique conic grid of Czech maps.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:5514",
projection: proj4(EPSG_4326, EPSG_5514),
// A square around Czechia, 1,040 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-1197000, -559000], extentAtZoom0: 1040000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[12.09, 48.55],
[18.86, 51.06],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:5514" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://ags.cuzk.gov.cz/arcgis1/services/ZTM/MapServer/WMSServer?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=0&STYLES=&SRS=EPSG:5514&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 11,
attribution: '© <a href="https://cuzk.gov.cz/">ČÚZK</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:3301 · Estonian Coordinate System of 1997
Estonia in L-EST97
The Land and Spatial Development Board's map from its WMTS, in L-EST97, Estonia's Lambert conformal conic grid.
- WMTS tiles
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3301 } from "crs-defs";
// Estonian Coordinate System of 1997 (L-EST97), a Lambert conformal conic grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3301",
projection: proj4(EPSG_4326, EPSG_3301),
// The Land Board's LEST tile matrix set: level 0 is one 256 px tile at 4,000 m per pixel,
// with its top-left corner at (40,500, 7,017,000).
tileMatrixSet: { origin: [40500, 7017000], extentAtZoom0: 256 * 4000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[21.76, 57.51],
[28.21, 59.68],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3301" },
sources: {
map: {
type: "raster",
// The WMTS template: {z}, {y}, {x} are TileMatrix, TileRow and TileCol.
tiles: ["https://tiles.maaamet.ee/tm/wmts/1.0.0/kaart/default/LEST/{z}/{y}/{x}.png"],
tileSize: 256,
maxzoom: 14,
bounds: [17.02, 53.88, 35.11, 62.86],
attribution: '© <a href="https://maaruum.ee/">Maa- ja Ruumiamet</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:2154 · RGF93 / Lambert-93
France in Lambert-93
IGN's Plan IGN from the Géoplateforme's WMTS, tiled in Lambert-93, the official projection of mainland France. Its tiles start at level 6 of the grid.
- WMTS tiles
- A grid that starts at level 6
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_2154 } from "crs-defs";
// RGF93 / Lambert-93, the official projection of mainland France.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:2154",
projection: proj4(EPSG_4326, EPSG_2154),
// IGN's 2154_10cm tile matrix set, from the Géoplateforme's WMTS capabilities: level 0 is one
// 256 px tile at 209,715.2 m per pixel, with its top-left corner at (0, 12,000,000).
tileMatrixSet: { origin: [0, 12000000], extentAtZoom0: 256 * 209715.2 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-5.2, 41.3],
[9.6, 51.1],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:2154" },
sources: {
map: {
type: "raster",
// The WMTS template: {z}, {y}, {x} are TileMatrix, TileRow and TileCol. Plan IGN starts at level 6.
tiles: [
"https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2.L93&STYLE=normal&TILEMATRIXSET=2154_10cm_6_20&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg",
],
tileSize: 256,
minzoom: 6,
maxzoom: 20,
// Where IGN has tiles, so the map asks for no others.
bounds: [-5.15, 41.32, 9.56, 51.09],
attribution: '© <a href="https://www.ign.fr/">IGN</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
// Below level 6 there are no tiles.
minZoom: 5,
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:25832 · ETRS89 / UTM 32N
Germany in UTM zone 32
BKG's TopPlusOpen map from its WMTS, in UTM zone 32, the grid most German states map in. BKG's grid starts with 6 by 5 tiles, so the map registers a bigger square and transformRequest takes the extra levels off the URL.
- WMTS tiles
- transformRequest
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_25832 } from "crs-defs";
// BKG's EU_EPSG_25832_TOPPLUS tile matrix set starts with 6 by 5 tiles at level 0. The map registers
// the 8 by 8 square of them, so BKG's level 0 is the map's level 3.
const LEVELS_ABOVE = 3;
const METERS_PER_PIXEL = 4891.969810251279;
// ETRS89 / UTM zone 32N, the grid most German states map in.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:25832",
projection: proj4(EPSG_4326, EPSG_25832),
// From the WMTS capabilities: 256 px tiles, with the grid's top-left corner at (-3,803,166, 8,805,908).
tileMatrixSet: {
origin: [-3803165.98427299, 8805908.08284866],
extentAtZoom0: 2 ** LEVELS_ABOVE * 256 * METERS_PER_PIXEL,
},
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[5.87, 47.27],
[15.04, 55.06],
];
// BKG names its levels 00 to 13: take off the levels the map adds above them.
export const transformRequest: maplibregl.RequestTransformFunction = (url) => ({
url: url.replace(
/EU_EPSG_25832_TOPPLUS\/(\d+)\//,
(_, z) => `EU_EPSG_25832_TOPPLUS/${String(Number(z) - LEVELS_ABOVE).padStart(2, "0")}/`,
),
});
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:25832" },
sources: {
map: {
type: "raster",
tiles: [
"https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/1.0.0/web/default/EU_EPSG_25832_TOPPLUS/{z}/{y}/{x}.png",
],
tileSize: 256,
minzoom: LEVELS_ABOVE,
maxzoom: 13 + LEVELS_ABOVE,
// Inside BKG's 6 by 5 tiles, so the map asks for no others.
bounds: [-12, 33, 32, 72],
attribution:
'© <a href="https://www.bkg.bund.de/">BKG</a> (2026) <a href="https://www.govdata.de/dl-de/by-2-0">dl-de/by-2-0</a>, <a href="https://sgx.geodatenzentrum.de/web_public/gdz/datenquellen/Datenquellen_TopPlusOpen.html">sources</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
minZoom: LEVELS_ABOVE - 1,
transformRequest,
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:27700 · OSGB36 / British National Grid
Great Britain's bedrock
The British Geological Survey's 1:625,000 bedrock geology, from its WMS in the British National Grid of Ordnance Survey maps. The geology is an overlay, so the sea is a background layer.
- WMS with {bbox}
- A transparent overlay
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_27700 } from "crs-defs";
// OSGB36 / British National Grid, the grid of Ordnance Survey maps.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:27700",
projection: proj4(EPSG_4326, EPSG_27700),
// A square around Great Britain, 2,490 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-931000, 1865000], extentAtZoom0: 2490000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-8.2, 49.9],
[1.8, 60.9],
];
export function createMap(container: string | HTMLElement, { dark = false } = {}) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:27700" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://ogc.bgs.ac.uk/cgi-bin/BGS_Bedrock_and_Superficial_Geology/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=GBR_BGS_625k_BLS&STYLES=&SRS=EPSG:27700&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png&TRANSPARENT=TRUE",
],
tileSize: 512,
maxzoom: 6,
attribution:
'Contains <a href="https://www.bgs.ac.uk/">British Geological Survey</a> materials © UKRI',
},
},
layers: [
// The geology is an overlay with nothing over the sea.
{
id: "sea",
type: "background",
paint: { "background-color": dark ? "#1b2633" : "#dfeaf2" },
},
{ id: "map", type: "raster", source: "map" },
],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:3057 · ISN93 / Lambert 1993
Iceland in ISN93
Iceland's national base map from Náttúrufræðistofnun's WMS, in ISN93, the country's Lambert conformal conic grid.
- WMS with {bbox}
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_3057 } from "crs-defs";
// ISN93 / Lambert 1993, Iceland's national grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:3057",
projection: proj4(EPSG_4326, EPSG_3057),
// A square around Iceland, 1,110 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-56000, 1053000], extentAtZoom0: 1110000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-24.55, 63.3],
[-13.5, 66.57],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:3057" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://gis.lmi.is/geoserver/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=nytt_grunnkort_samsett_naer_fjaer&STYLES=&SRS=EPSG:3057&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 11,
attribution: '© <a href="https://www.natt.is/">Náttúrufræðistofnun</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:2169 · LUREF / Luxembourg TM
Luxembourg, close up
The national geoportal's topographic map from its WMS, in LUREF, Luxembourg's transverse Mercator grid. The country is 82 km from north to south, so its grid is only 170 km wide.
- WMS with {bbox}
- A grid 170 km wide
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_2169 } from "crs-defs";
// LUREF / Luxembourg TM, Luxembourg's national grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:2169",
projection: proj4(EPSG_4326, EPSG_2169),
// A square around Luxembourg, 170 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-8000, 183000], extentAtZoom0: 170000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[5.73, 49.45],
[6.53, 50.18],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:2169" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://wms.geoportail.lu/opendata/service?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=topomap&STYLES=&SRS=EPSG:2169&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 8,
attribution: '© <a href="https://act.public.lu/">ACT</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:28992 · Amersfoort / RD New
The Netherlands in its national grid
The Dutch base map as vector tiles from PDOK, served in the national RD New grid with a ready-made style. Labels, icons and the dark style all work as on a Mercator map.
- Vector tiles
- An OGC tile matrix set
- Light and dark
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_28992 } from "crs-defs";
// Amersfoort / RD New, the Dutch national grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:28992",
projection: proj4(EPSG_4326, EPSG_28992),
// The NetherlandsRDNewQuad tile matrix set: level 0 is one 256 px tile at 3,440.64 m per pixel.
tileMatrixSet: { origin: [-285401.92, 903401.92], extentAtZoom0: 256 * 3440.64 },
});
// The Randstad, from Rotterdam to Amsterdam; zoom out for the whole country.
const bounds: maplibregl.LngLatBoundsLike = [
[4.1, 51.82],
[5.2, 52.45],
];
export async function createMap(container: string | HTMLElement, { dark = false } = {}) {
// PDOK publishes the Dutch base map as vector tiles and styles in the national grid.
// Its style works as is; it only needs to name the projection.
const name = dark ? "darkmode" : "standaard";
const url = `https://api.pdok.nl/kadaster/brt-achtergrondkaart/ogc/v1/styles/${name}__netherlandsrdnewquad?f=mapbox`;
const style: maplibregl.StyleSpecification = await (await fetch(url)).json();
style.projection = { type: "EPSG:28992" };
Object.assign(style.sources.brt, {
attribution:
'© <a href="https://www.kadaster.nl/">Kadaster</a>, via <a href="https://www.pdok.nl/">PDOK</a>',
});
const map = new maplibregl.Map({
container,
style,
// PDOK's two coarsest levels have no water, and its style leaves their land unfilled.
minZoom: 2,
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:25833 · ETRS89 / UTM 33N
Norway in UTM zone 33
Kartverket's topographic map, straight from its WMTS in the grid Norway's national maps are made in. Zoom in to the fjords: the scale bar reads true meters.
- WMTS tiles
- Scale bar in meters
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_25833 } from "crs-defs";
// ETRS89 / UTM zone 33N, the grid Norway's national maps are made in.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:25833",
projection: proj4(EPSG_4326, EPSG_25833),
// Kartverket's utm33n tile matrix set, from its WMTS capabilities: level 0 is one
// 256 px tile at 21,664 m per pixel, with its top-left corner at (-2,500,000, 9,045,984).
tileMatrixSet: { origin: [-2500000, 9045984], extentAtZoom0: 256 * 21664 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[4.5, 57.9],
[31.2, 71.2],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:25833" },
sources: {
topo: {
type: "raster",
// The WMTS template: {z}, {y}, {x} are TileMatrix, TileRow and TileCol in the national grid.
tiles: ["https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/utm33n/{z}/{y}/{x}.png"],
tileSize: 256,
maxzoom: 18,
attribution: '© <a href="https://www.kartverket.no/">Kartverket</a>',
},
},
layers: [{ id: "topo", type: "raster", source: "topo" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
// The scale bar reads true meters, because the grid is in meters.
map.addControl(new maplibregl.ScaleControl(), "bottom-left");
return map;
}EPSG:2180 · ETRS89 / Poland CS92
Poland in PL-1992
GUGiK's topographic map from the Geoportal WMS, in PL-1992, the grid of Poland's maps of the whole country.
- WMS with {bbox}
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_2180 } from "crs-defs";
// ETRS89 / Poland CS92, the grid of Poland's maps of the whole country.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:2180",
projection: proj4(EPSG_4326, EPSG_2180),
// A square around Poland, 1,470 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-225000, 1192000], extentAtZoom0: 1470000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[14.12, 49.0],
[24.15, 54.84],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:2180" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://mapy.geoportal.gov.pl/wss/service/img/guest/TOPO/MapServer/WMSServer?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=Raster&STYLES=&SRS=EPSG:2180&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 11,
attribution: '© <a href="https://www.geoportal.gov.pl/">GUGiK</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:25830 · ETRS89 / UTM 30N
Spain in UTM zone 30
IGN's national map from its WMTS, from the whole country down to the 1:25,000 sheets, in UTM zone 30, the zone that covers most of mainland Spain. IGN crops each level of its grid at the top, so transformRequest shifts the row in the URL.
- WMTS tiles
- transformRequest
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_25830 } from "crs-defs";
// IGN crops each level of its tile matrix set at the top: level z's first row is
// FIRST_ROW[z] rows below the grid's corner, from the corners in its WMTS capabilities.
const FIRST_ROW = [
0, 1, 3, 6, 12, 24, 49, 99, 199, 398, 796, 1592, 3185, 6371, 12743, 25485, 50971, 101943, 203887,
407775,
];
// ETRS89 / UTM zone 30N, the zone that covers most of mainland Spain.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:25830",
projection: proj4(EPSG_4326, EPSG_25830),
// IGN's EPSG:25830 tile matrix set: level 0 is one 256 px tile at 78,271.5 m per pixel,
// with its top-left corner at (-1,968,157, 22,856,087).
tileMatrixSet: { origin: [-1968157.095, 22856087], extentAtZoom0: 256 * 78271.51698 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-9.3, 35.95],
[4.33, 43.79],
];
// Count rows from each level's own first row, as IGN does.
export const transformRequest: maplibregl.RequestTransformFunction = (url) => ({
url: url.replace(
/TILEMATRIX=(\d+)&TILEROW=(\d+)/,
(_, z, y) => `TILEMATRIX=${z}&TILEROW=${Number(y) - FIRST_ROW[Number(z)]}`,
),
});
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:25830" },
sources: {
map: {
type: "raster",
// IGN's national map (MTN), from the whole country down to the 1:25,000 sheets.
// The WMTS template: {z}, {y}, {x} are TileMatrix, TileRow and TileCol.
tiles: [
"https://www.ign.es/wmts/mapa-raster?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=MTN&STYLE=default&TILEMATRIXSET=EPSG:25830&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg",
],
tileSize: 256,
maxzoom: 19,
// Inside IGN's grid, which ends at the top around 67°N, so the map asks for no tiles above its crop.
bounds: [-25, 25, 15, 62],
attribution: '© <a href="https://www.ign.es/">Instituto Geográfico Nacional</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
transformRequest,
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:2056 · CH1903+ / LV95
Switzerland in LV95
swisstopo's national map with its shaded relief, from its WMS, in LV95, the Swiss grid whose origin of 2,600,000 / 1,200,000 lies in Bern.
- WMS with {bbox}
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_2056 } from "crs-defs";
// CH1903+ / LV95, the Swiss national grid.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:2056",
projection: proj4(EPSG_4326, EPSG_2056),
// A square around Switzerland, 710 km wide. A WMS draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [2306000, 1542000], extentAtZoom0: 710000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[5.96, 45.82],
[10.49, 47.81],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:2056" },
sources: {
map: {
type: "raster",
// The WMS draws any box: {bbox} becomes each tile's box in the grid's meters.
tiles: [
"https://wms.geo.admin.ch/?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=ch.swisstopo.pixelkarte-farbe&STYLES=&SRS=EPSG:2056&BBOX={bbox}&WIDTH=512&HEIGHT=512&FORMAT=image/png",
],
tileSize: 512,
maxzoom: 10,
attribution: '© <a href="https://www.swisstopo.admin.ch/">swisstopo</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}EPSG:5070 · NAD83 / Conus Albers
The United States in Albers
The USGS imagery and topographic map, drawn by its ArcGIS server in Conus Albers, the equal-area projection US agencies use for the lower 48 states. The 49th parallel curves, as on a globe.
- ArcGIS export with {bbox}
- Equal area
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_5070 } from "crs-defs";
// NAD83 / Conus Albers, an equal-area projection for the lower 48 states.
// Register the CRS once, before any map whose style names it.
maplibregl.addProjection({
name: "EPSG:5070",
projection: proj4(EPSG_4326, EPSG_5070),
// A square around United States, 11,720 km wide. The server draws any box, so the grid is ours to pick.
tileMatrixSet: { origin: [-5845000, 7571000], extentAtZoom0: 11720000 },
});
// The box the map opens on. Boxes are in lng/lat, whatever the projection.
const bounds: maplibregl.LngLatBoundsLike = [
[-124.8, 24.5],
[-66.9, 49.4],
];
export function createMap(container: string | HTMLElement) {
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: "EPSG:5070" },
sources: {
map: {
type: "raster",
// The export URL draws any box in any projection: {bbox} becomes each tile's box.
tiles: [
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer/export?bbox={bbox}&bboxSR=5070&imageSR=5070&size=512,512&format=png&f=image",
],
tileSize: 512,
maxzoom: 14,
attribution:
'<a href="https://www.usgs.gov/programs/national-geospatial-program/national-map">USGS The National Map</a>',
},
},
layers: [{ id: "map", type: "raster", source: "map" }],
},
});
// Fit the box once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () => map.fitBounds(bounds, { padding: 16, animate: false }));
return map;
}The world in any projection
A whole basemap of the world from Natural Earth's GeoJSON, with countries, rivers, lakes, cities and seas, projected in MapLibre's workers with proj4. Pick a projection, or write your own.
Loading the map…
- A GeoJSON basemap
- GeoJSON in the workers
- Labels
- Hover queries
- Any proj4 projection
Map code
Plain MapLibre GL JS 6.15 with proj4 and crs-defs, ready to paste into a module: createMap("map") makes the map in the element with the id map. With a bundler, point MapLibre at its worker first, as in the quick start.
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import proj4 from "proj4";
import { EPSG_4326, EPSG_8857 } from "crs-defs";
// Natural Earth's 1:50m countries, borders, lakes, rivers, glaciers, cities and seas (public domain), in one
// file with a `kind` on each feature, and a point at least every degree. scripts/prepare-world-basemap.mjs makes it.
import basemapUrl from "./world-basemap.geojson?url";
/** The outline of the whole world: the ±180° meridians and the poles, a point every degree. */
const sphere: GeoJSON.Feature = {
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [
[
...Array.from({ length: 181 }, (_, i): [number, number] => [-180, -90 + i]),
...Array.from({ length: 361 }, (_, i): [number, number] => [-180 + i, 90]),
...Array.from({ length: 181 }, (_, i): [number, number] => [180, 90 - i]),
...Array.from({ length: 361 }, (_, i): [number, number] => [180 - i, -90]),
],
],
},
};
/** Lines of latitude and longitude as GeoJSON, with a point every degree so they curve with the projection. */
function graticule(step = 10, latRange: [number, number] = [-80, 80]): GeoJSON.FeatureCollection {
const features: GeoJSON.Feature[] = [];
const line = (coordinates: [number, number][]) =>
features.push({
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates },
});
const [south, north] = latRange;
for (let lat = Math.ceil(south / step) * step; lat <= north; lat += step) {
line(Array.from({ length: 361 }, (_, i) => [i - 180, lat]));
}
for (let lng = -180; lng < 180; lng += step) {
line(Array.from({ length: north - south + 1 }, (_, i) => [lng, south + i]));
}
return { type: "FeatureCollection", features };
}
/** The smallest square grid that holds the whole world, found by projecting a lng/lat grid of points. */
function worldSquare(projection: { forward(p: number[]): number[] }) {
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
for (let lng = -180; lng <= 180; lng += 2) {
for (let lat = -90; lat <= 90; lat += 2) {
const [x, y] = projection.forward([lng, lat]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(
"That projection can't show the whole world: places like the far side of an orthographic globe have no position in it.",
);
}
[minX, minY, maxX, maxY] = [
Math.min(minX, x),
Math.min(minY, y),
Math.max(maxX, x),
Math.max(maxY, y),
];
}
}
const side = Math.max(maxX - minX, maxY - minY) * 1.04;
const origin: [number, number] = [(minX + maxX - side) / 2, (minY + maxY + side) / 2];
return { origin, extentAtZoom0: side };
}
/** The basemap's colors, in the site's light and dark themes. */
const LIGHT = {
water: "#cfe1f2",
graticule: "#b3cbe3",
land: ["#f3e3c3", "#e3ebc6", "#f1d6cb", "#d4e8d6", "#e9dbe9", "#f6ecc8", "#dedaf0"],
ice: "#fbfdff",
river: "#9ec0e4",
border: "#8a8075",
sea: "#56789f",
city: "#3d3a36",
text: "#2f2b27",
country: "#5f554b",
halo: "rgba(255, 255, 255, 0.85)",
};
const DARK = {
water: "#15223a",
graticule: "#253656",
land: ["#3a3a46", "#343f3a", "#423a40", "#323f47", "#3d3848", "#433f35", "#373a4d"],
ice: "#56657a",
river: "#355f8f",
border: "#8792a8",
sea: "#7ea3d1",
city: "#dfe4ee",
text: "#e6e9f0",
country: "#c4cad6",
halo: "rgba(15, 23, 42, 0.85)",
};
// GeoJSON is cut into tiles in MapLibre's web workers, so they need proj4 too: projection-handler.mjs,
// below, builds the same converter there from the CRS's definition. Load it once, before the first map.
const workersReady = maplibregl.importScriptInWorkers(
new URL("/projection-handler.mjs", location.href).href,
);
/** A world map in any proj4 projection. Every one gets its own name, since a name is registered once. */
let count = 0;
export async function createMap(
container: string | HTMLElement,
{ definition = EPSG_8857, dark = false } = {},
) {
let projection;
try {
projection = proj4(EPSG_4326, definition);
} catch {
throw new Error(
"proj4 couldn't read that definition. github.com/birkskyum/crs-defs has the proj4 string of every EPSG and ESRI code.",
);
}
const tileMatrixSet = worldSquare(projection);
// Dragging and zooming convert screen points back to lng/lat, so the inverse has to work too.
for (const lngLat of [
[0, 0],
[100, 40],
[-60, -30],
]) {
if (!projection.inverse(projection.forward(lngLat)).every(Number.isFinite)) {
throw new Error(
"proj4 can't convert back from that projection, and the map needs that to pan and zoom.",
);
}
}
const name = `custom:world-${++count}`;
maplibregl.addProjection({
name,
// The same string goes to proj4 in the workers, which project the GeoJSON below.
definition,
// proj4 straight in: past the world's edge, where it wraps positions around, MapLibre stops at the edge.
projection,
tileMatrixSet,
});
await workersReady;
const c = dark ? DARK : LIGHT;
const kind = (name: string) => ["==", ["get", "kind"], name] as ["==", ["get", "kind"], string];
const map = new maplibregl.Map({
container,
style: {
version: 8,
projection: { type: name },
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
sources: {
sphere: { type: "geojson", data: sphere },
graticule: { type: "geojson", data: graticule(15, [-90, 90]) },
basemap: {
type: "geojson",
data: basemapUrl,
attribution: 'Made with <a href="https://www.naturalearthdata.com/">Natural Earth</a>',
},
},
layers: [
{ id: "ocean", type: "fill", source: "sphere", paint: { "fill-color": c.water } },
{
id: "graticule",
type: "line",
source: "graticule",
paint: { "line-color": c.graticule, "line-width": 0.6 },
},
{
id: "countries",
type: "fill",
source: "basemap",
filter: kind("country"),
// Natural Earth's 7-color scheme: neighbors never share a color.
paint: {
"fill-color": [
"match",
["get", "color"],
1,
c.land[0],
2,
c.land[1],
3,
c.land[2],
4,
c.land[3],
5,
c.land[4],
6,
c.land[5],
c.land[6],
],
},
},
{
id: "glaciers",
type: "fill",
source: "basemap",
filter: kind("glacier"),
paint: { "fill-color": c.ice },
},
{
id: "lakes",
type: "fill",
source: "basemap",
filter: kind("lake"),
paint: { "fill-color": c.water },
},
{
id: "rivers",
type: "line",
source: "basemap",
// Rivers by rank, 1 the biggest: the main ones from the start, the rest as the map zooms in.
filter: ["all", kind("river"), ["<=", ["get", "rank"], ["+", ["zoom"], 2]]],
layout: { "line-cap": "round", "line-join": "round" },
paint: {
"line-color": c.river,
"line-width": [
"interpolate",
["linear"],
["zoom"],
0,
["max", 0.3, ["-", 1.4, ["*", 0.12, ["get", "rank"]]]],
5,
["max", 0.6, ["-", 3, ["*", 0.2, ["get", "rank"]]]],
],
},
},
{
id: "state-borders",
type: "line",
source: "basemap",
filter: kind("state-border"),
minzoom: 1.5,
paint: {
"line-color": c.border,
"line-opacity": 0.45,
"line-width": 0.5,
"line-dasharray": [3, 2],
},
},
{
id: "borders",
type: "line",
source: "basemap",
filter: ["all", kind("border"), ["!", ["get", "disputed"]]],
layout: { "line-join": "round" },
paint: {
"line-color": c.border,
"line-width": ["interpolate", ["linear"], ["zoom"], 0, 0.5, 5, 1.4],
},
},
{
id: "disputed-borders",
type: "line",
source: "basemap",
filter: ["all", kind("border"), ["get", "disputed"]],
paint: { "line-color": c.border, "line-width": 0.8, "line-dasharray": [2, 2] },
},
{
id: "sea-labels",
type: "symbol",
source: "basemap",
// Oceans first; seas and gulfs as the map zooms in to them.
filter: ["all", kind("sea-label"), ["<=", ["get", "rank"], ["+", ["zoom"], 1.5]]],
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Italic"],
"text-size": ["case", ["get", "ocean"], 14, 11],
"text-letter-spacing": ["case", ["get", "ocean"], 0.3, 0.1],
"text-max-width": 6,
"symbol-sort-key": ["get", "rank"],
},
paint: { "text-color": c.sea, "text-halo-color": c.water, "text-halo-width": 1 },
},
{
id: "city-dots",
type: "circle",
source: "basemap",
filter: ["all", kind("city"), ["<=", ["get", "rank"], ["+", ["zoom"], 1.2]]],
paint: {
"circle-radius": ["case", ["get", "capital"], 3, 2],
"circle-color": c.city,
"circle-stroke-color": c.halo,
"circle-stroke-width": 1,
},
},
{
id: "city-labels",
type: "symbol",
source: "basemap",
filter: ["all", kind("city"), ["<=", ["get", "rank"], ["+", ["zoom"], 1.2]]],
layout: {
"text-field": ["get", "name"],
"text-font": [
"case",
["get", "capital"],
["literal", ["Noto Sans Bold"]],
["literal", ["Noto Sans Regular"]],
],
"text-size": ["case", ["get", "capital"], 12, 11],
"text-variable-anchor": ["left", "right", "top", "bottom"],
"text-radial-offset": 0.5,
"symbol-sort-key": ["get", "rank"],
},
paint: { "text-color": c.text, "text-halo-color": c.halo, "text-halo-width": 1.2 },
},
{
id: "country-labels",
type: "symbol",
source: "basemap",
// Big countries from the start, smaller ones as the map zooms in.
filter: ["all", kind("country-label"), ["<=", ["get", "rank"], ["+", ["zoom"], 2.5]]],
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Bold"],
"text-size": ["interpolate", ["linear"], ["zoom"], 0, 9, 4, 13],
"text-transform": "uppercase",
"text-letter-spacing": 0.12,
"text-max-width": 7,
"symbol-sort-key": ["get", "rank"],
},
paint: { "text-color": c.country, "text-halo-color": c.halo, "text-halo-width": 1.2 },
},
],
},
// Natural Earth's 1:50m data has detail to about here; closer in, its coastlines only blur.
maxZoom: 5,
});
// Fit the whole world once the style has set the projection: the bounds option would fit it in Mercator.
map.once("style.load", () =>
map.fitBounds(
[
[-180, -90],
[180, 90],
],
{ padding: 16, animate: false },
),
);
// Queries return lng/lat and the source's own properties, as on any map.
const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false });
map.on("mousemove", "countries", (e) => {
popup.setLngLat(e.lngLat).setText(String(e.features?.[0]?.properties.name)).addTo(map);
});
map.on("mouseleave", "countries", () => popup.remove());
return map;
}projection-handler.mjs, a file next to the page that the code above loads into MapLibre's web workers with maplibregl.importScriptInWorkers. They cut GeoJSON into tiles, so they need proj4 too: it builds the converter there from the CRS's definition.
// Runs in MapLibre's web workers, which cut GeoJSON into tiles: it turns a CRS's
// `definition` into the same proj4 converter the page uses. Keep proj4's version in step with the page's.
import proj4 from "https://cdn.jsdelivr.net/npm/proj4@2.22.0/+esm";
self.setProjectionHandler((definition) => proj4(definition));How it works
A map in a custom coordinate reference system (CRS) takes three things.
Step 1
Describe the grid
Register the CRS once with maplibregl.addProjection: a name, a converter from lng/lat to its coordinates (proj4 makes one from any definition), and the tile grid's top-left corner and width.
Step 2
Load tiles made in it
Raster and vector tiles, WMTS and WMS (with the {bbox} token), DEM, image and video sources: anything served in the CRS's square grid. The map positions the tiles, it doesn't reproject them.
Step 3
Keep using lng/lat
Markers, popups, fitBounds, flyTo, project, queryRenderedFeatures and GeoJSON all work in lng/lat, as on a Mercator map. The map converts through the CRS.
Good to know
- It's experimental, so the API can still change in a minor release.
- Tiles must be made in the CRS. Web Mercator tiles aren't reprojected, so a Mercator basemap won't line up.
- One CRS per map, flat: no globe and no world copies.
- The grid must be a square quad tree, where each zoom level splits every tile in four.
- Pick the projection in the style before the sources load. To switch, make a new map, as this page does.
Go further
- MapLibre's guide to custom CRSs: every option, GeoJSON in the workers, and grids whose first level has several tiles.
- crs-defs gives the proj4 string of any EPSG or ESRI code, with its datum shift checked against PROJ. The maps here take theirs from it.
- MapLibre GL JS on this site, with a quick start.