# Integrations: Leaflet, OpenLayers, deck.gl, Cesium, QGIS

MapMap's map surface is made of formats other people's software already
reads: Mapbox Vector Tiles, TileJSON 3.0, MapLibre GL style JSON, PMTiles
archives, and SDF glyph ranges. Nothing on it is a MapMap file format. So
the map renders in clients that have never heard of MapMap, and this page is
the copy-pasteable version of that for the five asked about most.

For MapLibre GL JS itself, and for the `@mapmap/maps` wrapper around it, see
[Maps, tiles & Studio](/docs/maps). For MapLibre Native on iOS and Android,
see [Mobile maps](/docs/mobile). This page is everything else.

Set up your shell once; the snippets below use the same two values:

```sh
export BASE=https://api.mapmap.ai   # or your self-host gateway
export API_KEY=snk_…                # issued by POST /v1/keys; see the quickstart
```

## What MapMap serves, and what each client can read

| Surface | URL | Key | Read by |
|---|---|---|---|
| Vector tiles (MVT) | `$BASE/tiles/{territory}/{z}/{x}/{y}.mvt` | yes | Everything on this page |
| TileJSON 3.0 | `$BASE/tiles/{territory}/tiles.json` | yes | Leaflet (via MapLibre), OpenLayers, deck.gl, QGIS |
| Compiled MapLibre style | `$BASE/tiles/{territory}/style.json` | yes | Leaflet (via MapLibre), OpenLayers (via ol-mapbox-style) |
| House styles, keyless | `$BASE/styles/mapmap-light-76ef8d@2.json` | no | Leaflet, via MapLibre with the `pmtiles` protocol registered |
| PMTiles archive | `https://tiles.mapmap.ai/{territory}.pmtiles` | no | MapLibre, deck.gl, GDAL and QGIS |
| SDF glyphs | `https://fonts.mapmap.ai/{fontstack}/{range}.pbf` | no | MapLibre only |

`.pbf` is accepted as a tile extension wherever `.mvt` is, which matters for
clients whose URL templates assume it (QGIS is one). The key goes on as
`?api_key=snk_…` or as `Authorization: Bearer snk_…`; both are verified to
work on the tile route, and the query form exists because a style document
referenced by bare URL cannot attach a header.

### Four things that decide every snippet below

**1. There are no raster tiles.** MapMap serves vector tiles and expects the
client to render them. Server-side raster or PNG tile rendering is not
offered, and neither is a `{z}/{x}/{y}.png` endpoint. Any client that can
only consume raster tiles (Leaflet's own `L.tileLayer`, Cesium's
`UrlTemplateImageryProvider`) cannot show a MapMap basemap. That is a real
limit, not a configuration you have missed. The one raster surface is
[static map images](/docs/api-reference#static-map-images), which renders a
whole picture rather than a tile pyramid.

**2. The key travels the way you sent it.** `GET
/tiles/{territory}/style.json?api_key=snk_…` returns a style whose source
points at `https://api.mapmap.ai/tiles/uk/tiles.json?api_key=snk_…`, and that
TileJSON in turn lists `…/{z}/{x}/{y}.mvt?v=…&api_key=snk_…`. So a client
that follows the document verbatim stays authenticated all the way down to
the tiles, and the style-based snippets below need no request hook.

Send the key as `Authorization: Bearer snk_…` instead and the URLs come back
keyless, which is right for that client: it is attaching the header itself on
every fetch, and a key it kept out of the URL is not going to be written into
one for it. Pick one form per client rather than mixing them. A hook that
puts the key on is still the answer when the document was fetched some other
way, out of a build step or from a proxy of your own, but it is no longer
load-bearing for the snippets here.

**3. An in-range tile with no data is `204`, not `404`.** Ocean, and the
sparse edges of a territory. Treat an empty body as an empty tile; a client
that logs `204` as a failure will look broken over the sea.

**4. `tiles.mapmap.ai` gates browser origins.** The PMTiles CDN is our own
surface, and a request declaring an `Origin` or `Referer` it does not
recognise is refused:

```json
{
  "error": "hosted basemap tiles require an API key from this origin",
  "tiles_api": "https://api.mapmap.ai/tiles/:territory/:z/:x/:y.pbf",
  "signup": "https://mapmap.ai/signup"
}
```

`localhost` and `127.0.0.1` are allowed, so the keyless snippets here run on
a development server. A request declaring neither header, which is what
QGIS, GDAL, curl and a native app send, is served. A request from your own
deployed site is not: ship the keyed tile API for anything on a domain of
your own. This is the same gate described in
[Mobile maps](/docs/mobile#hosted-house-styles-keyless).

## Leaflet

Leaflet has no vector-tile renderer of its own. The working combination is
[`@maplibre/maplibre-gl-leaflet`](https://github.com/maplibre/maplibre-gl-leaflet),
which puts a MapLibre GL canvas inside a Leaflet layer: Leaflet keeps the
map object, the controls, the markers and the plugin ecosystem, MapLibre
does the drawing. The library is small and the integration is one call.

> **Pin MapLibre 5 for this recipe.** `@maplibre/maplibre-gl-leaflet` is a
> classic script that reads the `maplibregl` **browser global**. MapLibre 6 is
> ESM-only and creates no global, and the plugin has shipped no ESM build, so
> the working combination is still MapLibre 5.x. Pin it explicitly rather than
> floating: `maplibre-gl@5` on a CDN will stop resolving the UMD file the day
> the tag moves.

```html
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.1/leaflet-maplibre-gl.js"></script>

<div id="map" style="height: 100vh"></div>

<script>
  const API_KEY = "snk_…";

  const map = L.map("map", { center: [51.5074, -0.1278], zoom: 12 }); // lat, lon

  // The key on the style URL carries through to the TileJSON and the
  // tiles, so no transformRequest hook is needed.
  L.maplibreGL({
    style: "https://api.mapmap.ai/tiles/uk/style.json?api_key=" + API_KEY,
  }).addTo(map);

  map.attributionControl.addAttribution(
    '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>' +
      ' &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a>'
  );
</script>
```

If you are already on MapLibre 6 elsewhere in the app, the plugin still works
provided you put the namespace on the global **before** the plugin script
evaluates, but you then own two MapLibre majors on one page, which is exactly
the `[duplicate-maplibre]` failure. Prefer MapLibre GL JS directly.

Notes that save an hour:

- Leaflet takes `[lat, lon]`; MapLibre and every MapMap API take
  `[lon, lat]`. The two coordinate orders sit in the same file here.
- The container needs an explicit height, as it does for plain MapLibre. No
  height means a canvas zero pixels tall and a page that looks blank with no
  error. See [troubleshooting a blank map](/docs/maps#troubleshooting-a-blank-map).
- Leaflet's own zoom, pan and inertia drive the MapLibre camera, so bearing
  and pitch are not available through this layer. If you need a tilted or
  rotated map, you want MapLibre GL JS directly.
- `L.tileLayer` cannot be pointed at MapMap. It wants raster tiles and there
  are none.

For evaluation without a key, swap the `style` for a house style. It reads
the PMTiles archive over range requests, so register the protocol first:

```html
<script src="https://cdn.jsdelivr.net/npm/pmtiles@4.4.0/dist/pmtiles.js"></script>
<script>
  // `maplibregl` here is the MapLibre 5 global the pinned script above
  // created. MapLibre 6 creates no global; see the note on the recipe.
  maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile);
  L.maplibreGL({
    style: "https://api.mapmap.ai/styles/mapmap-light-76ef8d@2.json",
  }).addTo(map);
</script>
```

That path is subject to the origin gate above, so it is for local work and
demos rather than for a site you ship.

## OpenLayers

[`ol-mapbox-style`](https://github.com/openlayers/ol-mapbox-style) reads a
MapLibre or Mapbox GL style document and configures OpenLayers layers from
it, which means the MapMap style is the whole integration: colours, zoom
ranges, filters and label placement all come across.

```html
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.7.0/ol.css" />
<script src="https://cdn.jsdelivr.net/npm/ol@10.7.0/dist/ol.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ol-mapbox-style@12.6.1/dist/olms.js"></script>

<div id="map" style="height: 100vh"></div>

<script>
  const API_KEY = "snk_…";

  const map = new ol.Map({
    target: "map",
    view: new ol.View({ center: ol.proj.fromLonLat([-0.1278, 51.5074]), zoom: 12 }),
    controls: ol.control.defaults
      .defaults({ attribution: false })
      .extend([new ol.control.Attribution({ collapsible: false })]),
  });

  olms
    .apply(map, "https://api.mapmap.ai/tiles/uk/style.json?api_key=" + API_KEY, {
      // MapMap ships labels as SDF glyphs, which OpenLayers cannot read.
      // Point webfonts at a real CSS for the family you want labels set in.
      webfonts:
        "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans/{fontweight}{-fontstyle}.css",
    })
    .then(() => {
      map.getLayers().forEach(
        (l) =>
          l.getSource() &&
          l.getSource().setAttributions(
            '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>' +
              ' &copy; <a href="https://openmaptiles.org/">OpenMapTiles</a>'
          )
      );
    });
</script>
```

Notes:

- **Fonts are the one thing that does not carry over.** MapLibre renders
  labels from the SDF glyph ranges at `fonts.mapmap.ai`; OpenLayers draws
  text with the browser's own font engine and ignores them. `ol-mapbox-style`
  tries to resolve the style's fontstack name (`MapMap Sans Regular`) against
  the Fontsource CDN, finds nothing, and falls back to a system font. Pass
  `webfonts` as a template string, as above, to choose the fallback
  deliberately. It is a template, not a function: passing a function throws
  `t.replace is not a function` and the map paints nothing.
- `webfonts` is a string with `{font-family}`, `{fontweight}` and
  `{-fontstyle}` placeholders. The example pins one family for every stack,
  which is usually what you want when the style names a font you do not
  license.
- OpenLayers collapses the attribution into an `i` button by default. The
  `collapsible: false` control above keeps the OpenStreetMap and
  OpenMapTiles credits on screen, which is a licence condition rather than a
  style preference.
- Terrain, hillshade and 3D building extrusions in a MapMap theme have no
  OpenLayers equivalent and are dropped. The 2D map is complete.

## deck.gl

deck.gl is the right client when the map is a substrate for your own data
and you want the road network as geometry rather than as a picture.
`MVTLayer` reads our tiles directly.

```html
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/deck.gl@9.1.14/dist.min.js"></script>

<div id="map" style="position: absolute; inset: 0; background: #12161c"></div>
<div style="position: absolute; right: 0; bottom: 0; z-index: 1;
     font: 12px/1.6 system-ui, sans-serif; background: rgba(0,0,0,.6); color: #fff; padding: 0 6px">
  &copy; <a style="color:#9cf" href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>
  &copy; <a style="color:#9cf" href="https://openmaptiles.org/">OpenMapTiles</a>
</div>

<script>
  const API_KEY = "snk_…";

  const roads = new deck.MVTLayer({
    id: "mapmap-roads",
    data: `https://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.mvt?api_key=${API_KEY}`,
    minZoom: 0,
    maxZoom: 14,
    // Read one source layer out of the tile rather than all sixteen.
    loadOptions: { mvt: { layers: ["transportation"] } },
    getLineColor: (f) =>
      f.properties.class === "motorway" ? [232, 119, 34] : [110, 130, 155],
    getLineWidth: (f) => (f.properties.class === "motorway" ? 24 : 8),
    lineWidthMinPixels: 0.4,
    pickable: true,
  });

  new deck.DeckGL({
    container: document.getElementById("map"),
    initialViewState: { longitude: -0.1278, latitude: 51.5074, zoom: 11 },
    controller: true,
    layers: [roads],
  });
</script>
```

Notes:

- **Either the tile template or the TileJSON URL works**, as long as the key
  is on the URL you hand over. `MVTLayer` accepts both, and a TileJSON
  fetched with `?api_key=` lists tile templates that carry the key on, so
  the tiles it goes on to fetch are authenticated. Handing it a TileJSON URL
  with no key on it still ends in `401` on every tile.
- `loadOptions.mvt.layers` is worth setting. A MapMap tile carries up to
  sixteen source layers (`aerodrome_label`, `aeroway`, `boundary`,
  `building`, `housenumber`, `landcover`, `landuse`, `mountain_peak`, `park`,
  `place`, `poi`, `transportation`, `transportation_name`, `water`,
  `water_name`, `waterway`); without a filter every one of them is parsed
  and styled by the same accessors.
- deck.gl draws no attribution of its own. The credit above is plain HTML
  over the canvas, and it is not optional.
- There is no MapMap style to hand deck.gl. You write the accessors. If you
  want the MapMap cartography underneath your data, run MapLibre as a base
  map and deck.gl as an overlay (`MapboxOverlay` in interleaved mode), which
  is the standard deck.gl and MapLibre pairing and needs nothing specific to
  MapMap.

### deck.gl over a PMTiles archive

For an unmetered read of a whole territory, point deck.gl at the PMTiles
archive through `@loaders.gl/pmtiles`. Pin it to a 4.x that matches the
loaders.gl your deck.gl release bundles; `@loaders.gl/pmtiles@4.3.4` against
`deck.gl@9.1.14` is the pairing checked here:

```html
<script src="https://cdn.jsdelivr.net/npm/deck.gl@9.1.14/dist.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@loaders.gl/pmtiles@4.3.4/dist/dist.min.js"></script>

<script>
  const source = loaders.PMTilesSource.createDataSource(
    "https://tiles.mapmap.ai/uk.pmtiles",
    {}
  );

  const roads = new deck.TileLayer({
    id: "mapmap-roads-pmtiles",
    minZoom: 0,
    maxZoom: 14,
    getTileData: ({ index }) =>
      source.getVectorTile({
        ...index,
        zoom: index.z,
        loadOptions: {
          mvt: { coordinates: "wgs84", tileIndex: index, layers: ["transportation"] },
        },
      }),
    renderSubLayers: (props) =>
      new deck.GeoJsonLayer({
        id: props.id,
        data: props.data,
        stroked: false,
        filled: false,
        getLineColor: [110, 130, 155],
        getLineWidth: 8,
        lineWidthMinPixels: 0.4,
      }),
  });
</script>
```

`MVTLayer` does not accept a PMTiles source object as its `data` (it treats
`data` as a URL and throws `Invalid URL`), which is why this uses `TileLayer`
with an explicit `getTileData`. The `loadOptions.mvt` block is where the
coordinate mode and the tile index go; without `coordinates: "wgs84"` and
`tileIndex` the features come back with null coordinates and nothing draws.
Remember the origin gate: this reads `tiles.mapmap.ai`, so it works from
`localhost` and from a native context, and not from your own domain.

## Cesium

Be plain about this one: **MapMap has no basemap for Cesium.**

- Our tiles are Mapbox Vector Tiles. Cesium's 3D basemap format is 3D Tiles.
  They are different formats for different things, and MapMap publishes no
  3D Tiles tileset.
- Cesium's imagery providers want raster tiles, and MapMap serves none.
  Rendering our vector tiles to raster in a headless browser and feeding
  Cesium the result is a thing people do; it is not something MapMap offers,
  supports or prices, so nothing here describes how.

What does work is using MapMap as the *data* layer over whatever imagery
your Cesium scene already has. A route with `geometries=geojson`, and an
isochrone's contours, are ordinary GeoJSON, and Cesium draws them without any
adapter:

```html
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/Widgets/widgets.css" />
<script src="https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/Cesium.js"></script>
<style>html, body, #c { height: 100%; margin: 0 }</style>

<div id="c"></div>
<div style="position:absolute;right:0;bottom:0;z-index:1;font:12px system-ui;
     background:rgba(0,0,0,.6);color:#fff;padding:0 6px">
  &copy; <a style="color:#9cf" href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>
</div>

<script>
  window.CESIUM_BASE_URL = "https://cdn.jsdelivr.net/npm/cesium@1.145.0/Build/Cesium/";

  (async () => {
    const viewer = new Cesium.Viewer("c", { baseLayer: false, baseLayerPicker: false });
    viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#12161c");

    // The keyless demo lane: standard profiles need no key.
    const r = await fetch(
      "https://api.mapmap.ai/route/v1/driving/-0.1278,51.5074;-1.5106,52.4081" +
        "?overview=full&geometries=geojson"
    ).then((x) => x.json());

    viewer.entities.add({
      polyline: {
        positions: Cesium.Cartesian3.fromDegreesArray(
          r.routes[0].geometry.coordinates.flat()
        ),
        width: 5,
        clampToGround: true,
        material: Cesium.Color.fromCssColorString("#e87722"),
      },
    });
    await viewer.zoomTo(viewer.entities);
  })();
</script>
```

`baseLayer: false` is what lets this run with no Cesium ion token; add your
own imagery and terrain in its place. Swap the URL for `POST /isochrone` and
the pattern is the same, except that its contours are a FeatureCollection, so
`Cesium.GeoJsonDataSource.load` takes the response directly.

Point clouds are a separate story. MapMap's own LiDAR and photogrammetry
rendering ships as [`@mapmap/points`](/docs/sdks), which draws inside a
MapLibre WebGL context, not a Cesium one.

## QGIS

QGIS reads our tiles two ways, and they behave differently enough to be
worth choosing between.

### As a vector tiles connection (styled, keyed)

*Layer* → *Add Layer* → *Add Vector Tile Layer…*, then *New* for a
connection. The dialog fields, and what to put in them:

| Field | Value |
|---|---|
| Name | Anything, for example `MapMap UK` |
| Source URL | `https://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.pbf?api_key=snk_…` |
| Min. Zoom Level | `0` |
| Max. Zoom Level | `14` |
| Style URL | Leave empty (see below) |
| Referer | Leave empty |

Check the URL before you paste it into a dialog that will not tell you why
it failed:

```sh
curl -fsS -o tile.pbf -w '%{http_code} %{size_download} %{content_type}\n' \
  "$BASE/tiles/uk/12/2046/1362.pbf?api_key=$API_KEY"
# 200 212340 application/vnd.mapbox-vector-tile
```

A `401` means the key did not travel; a `204` means you asked for a tile
with nothing in it, which is a real answer and not an error.

QGIS applies its own default cartography to the layer, one style per
geometry type. Leaving *Style URL* empty is still the simpler path: the key
now travels from a `?api_key=` style URL through to the tiles, but the style
also names SDF fontstacks QGIS does not render, so the labels are the part
that will not come across. Style the layer in QGIS instead, or export a
style from [Studio](/studio) as a reference for the colours.

### As a PMTiles archive (unstyled, keyless)

QGIS reads PMTiles through GDAL, which has a PMTiles driver, and GDAL can
read it in place over HTTP range requests. *Layer* → *Add Layer* →
*Add Vector Layer…*, source type *Protocol: HTTP(S), cloud, etc.*, and the
URI:

```
/vsicurl/https://tiles.mapmap.ai/uk.pmtiles
```

QGIS has no `Origin` header to send, so the browser-origin gate does not
apply. Confirm what you are about to open first:

```sh
ogrinfo -so "/vsicurl/https://tiles.mapmap.ai/uk.pmtiles"
```

```
INFO: Open of `/vsicurl/https://tiles.mapmap.ai/uk.pmtiles'
      using driver `PMTiles' successful.
Metadata:
  attribution=<a href="https://www.openmaptiles.org/" …>&copy; OpenMapTiles</a> …
  name=OpenMapTiles
  ZOOM_LEVEL=14
1: aerodrome_label
2: aeroway
3: boundary
…
16: waterway
```

This opens as sixteen ordinary vector layers you can query, filter, join and
run geoprocessing against, which the vector tiles connection does not give
you. It is not a styled basemap: there is no cartography, and every layer
arrives with QGIS's default symbology. Use the connection above when you
want a backdrop, and this when you want the data.

Territory slugs are listed in the [territories guide](/docs/territories);
`planet.pmtiles` is the worldwide archive and is large, so prefer a
territory archive when one covers your area of interest.

## MapLibre Native

iOS and Android are covered on their own page, because the failure modes are
different: an origin-restricted key cannot work in a native app, and
offline rendering comes from an installed territory package rather than from
a URL. See [Mobile maps with MapLibre Native](/docs/mobile), and
[SDKs & installation](/docs/sdks) for the install coordinates.

## Attribution

Map data derives from OpenStreetMap and the tile schema from OpenMapTiles.
Anything you render or republish must credit
**© OpenStreetMap contributors © OpenMapTiles**, with a link to
[openstreetmap.org/copyright](https://www.openstreetmap.org/copyright).

MapLibre reads the credit off the style's tile source and renders it for
you. Every other client on this page does not, which is why each snippet
carries the credit explicitly:

- **Leaflet**: `map.attributionControl.addAttribution(…)`.
- **OpenLayers**: an attribution control with `collapsible: false`, so the
  credit is on screen rather than behind a button.
- **deck.gl** and **Cesium**: plain HTML over the canvas.
- **QGIS**: the archive metadata carries it; put it on any map you export.

Removing the credit is a licence breach, not a style choice. See
[licensing](/docs/licensing) for the ODbL boundary.

## How these snippets were checked

Every browser snippet on this page was run against the live hosted gateway
in a headless Chromium (Playwright 1.63, SwiftShader WebGL) served from
`http://127.0.0.1`, with the network log recorded and the rendered page
screenshotted. The bar was: no 4xx or 5xx on any request, and a canvas with
real map content rather than a single flat colour.

| Snippet | Result |
|---|---|
| Leaflet, keyed style | 9 MapMap requests, all `200`, tiles rendered |
| Leaflet, keyless house style | style `200`, 8 PMTiles range reads `206`, tiles rendered |
| OpenLayers, ol-mapbox-style | 9 MapMap requests, all `200`, labels and roads rendered |
| deck.gl, `MVTLayer` over the keyed tile API | 6 tile requests, all `200`, road network rendered |
| deck.gl, `TileLayer` over `uk.pmtiles` | 9 range reads, all `206`, road network rendered |
| Cesium, route geometry over an empty globe | route `200`, 154 km polyline drawn, no ion token |

The QGIS URLs were checked with `curl` and `ogrinfo` (GDAL 3.13.3), whose
output is quoted above. The QGIS dialog fields are the labels QGIS uses, not
a paraphrase; the client versions in the snippets are the ones that were
run, and pinning them is the difference between a snippet that works and one
that used to.

## Next steps

- [Maps, tiles & Studio](/docs/maps): tiles, styles, themes and the web SDK
- [Mobile maps](/docs/mobile): MapLibre Native on iOS and Android
- [Add your own data](/docs/custom-data): your data over a MapMap basemap
- [Territories](/docs/territories): which territories exist and what they cover
- [Conventions](/docs/conventions): base URL, auth, errors, quotas
