Skip to content
Make with

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));

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;
}

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.

Edit the proj4 definition to draw your own. Keep +lon_0=0: the countries are cut at 180°, so another central meridian would stretch the ones that cross the new edge.

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.

  1. 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.

  2. 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.

  3. 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.