Documentation menu
Maps: vector tiles, styles & Studio
MapMap is a maps platform as well as a routing one: the gateway serves vector tiles, TileJSON and ready-to-render MapLibre styles straight from the same territory data the routing engine uses. Style them in Studio, or let an agent style them through the MCP style tools.
Status, honestly: the hosted gateway at https://api.mapmap.ai is live:
sign up for a key, or run the self-host distro;
every call below works identically against your own deployment.
These endpoints are enabled per deployment: they answer 404 until the
operator stages the tile and asset directories (the hosted gateway has them
enabled, with territories on every continent; see
territories for coverage).
Set up your shell once so every snippet on this page is copy-pasteable:
export BASE=https://api.mapmap.ai # or your self-host gateway
export API_KEY=snk_… # issued by POST /v1/keys; see the quickstart
Metered endpoints accept the key as Authorization: Bearer $API_KEY
(preferred) or as an ?api_key=snk_… query parameter; the query form exists
for URL-only contexts like a style URL handed to MapLibre. Ask for a style or
a TileJSON with ?api_key= and every URL in the document comes back carrying
the same key, so a client that just follows them keeps authenticating; ask
with the header and the URLs stay keyless, because that client keeps sending
its header. No key yet? The
quickstart issues one in a single curl; base URL, auth
and the error envelope are defined in full on the
conventions page.
Vector tiles
| Method | Path | What it returns |
|---|---|---|
| GET | /tiles/{territory}/{z}/{x}/{y}.mvt | One Mapbox Vector Tile (.pbf also accepted), gzip-encoded, strong ETag, immutable caching. In-range tile with no data is 204 |
| GET | /tiles/{territory}/tiles.json | TileJSON 3.0: zoom range, bounds and centre from the territory archive, the OpenStreetMap and OpenMapTiles credits, and data_updated (the OSM extract date behind the territory's latest published package) |
| GET | /tiles/{territory}/style.json | A ready-to-render MapLibre GL style over the territory's vector source. Its source URL carries your key when you asked with ?api_key=, so MapLibre resolves the TileJSON and the tiles without further help |
All three take your normal API key and are metered per request at the Standard price class (see pricing). They are not charged against the offline download allowance, which applies only to bulk territory-package downloads.
Territory slugs (uk below) are listed in the
territories guide; on a self-hosted deployment
GET /territories reports what you have staged.
curl -fsS "$BASE/tiles/uk/tiles.json" -H "Authorization: Bearer $API_KEY"
{
"tilejson": "3.0.0",
"name": "uk",
"scheme": "xyz",
"tiles": ["https://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.mvt?v=1720981132"],
"minzoom": 0,
"maxzoom": 14,
"bounds": [-8.65, 49.86, 1.77, 60.86],
"center": [-2.0, 54.0, 6.0],
"attribution": "© OpenStreetMap contributors © OpenMapTiles",
"data_updated": "2026-07-01"
}
(The ?v= discriminator busts caches atomically when the operator republishes
a territory archive; keep it in the URLs you pass through.)
That sample was fetched with the Authorization header, so the tile template
comes back keyless. The same request as
"$BASE/tiles/uk/tiles.json?api_key=$API_KEY" returns
…/{z}/{x}/{y}.mvt?v=1720981132&api_key=snk_… instead. Either way the
document is usable as it stands, which is the point: whichever way you sent
the key is the way the URLs in the answer expect you to keep sending it.
data_updated is the ISO-8601 date of the OSM extract behind the territory's
latest published package, read from the update channel the operator publishes
alongside the tiles. It is null when the deployment has no channel
configured or the territory is not published there; null means unknown,
never "current", so show nothing rather than today's date.
Stable vs hash-versioned tile URLs
The PMTiles CDN serves every territory archive under two names:
- Stable alias:
https://tiles.mapmap.ai/uk.pmtiles,https://tiles.mapmap.ai/planet.pmtiles. Always points at the current build; survives every rebuild. Use this in demos, docs and anything long-lived. The web SDK's defaults use the stable aliases. - Hash-versioned snapshot:
uk-a4b331db419a.pmtiles. Immutable and cacheable forever, but a world/tile rebuild retires it: any app that hardcodes a hashed name breaks on the next data refresh. Only pin a hashed snapshot when you specifically need a frozen dataset (a reproducible benchmark, a point-in-time archive), and expect it to be garbage-collected eventually.
If you copied a hashed URL out of your browser's network tab, swap it for the stable alias before shipping.
Render a map in one move: point MapLibre GL at the style URL. This example is
complete; note the container div must have an explicit height, or the
map renders zero pixels tall and the page looks silently blank (the classic
MapLibre gotcha, see troubleshooting,
including the Tailwind v4 cascade-layer variant of it):
<!-- MapLibre GL JS 6 is ESM-only: there is no UMD build and no `maplibregl`
browser global, so a plain page needs an import map and a module script.
In production, pin an exact maplibre-gl version (as here) and add SRI to
the stylesheet and the import map, or bundle it via npm. Regenerate an
SRI hash with:
curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A -->
<link href="https://unpkg.com/maplibre-gl@6.11.2/dist/maplibre-gl.css" rel="stylesheet" />
<div id="map" style="height: 100vh"></div>
<script type="importmap">
{ "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6.11.2/dist/maplibre-gl.mjs" } }
</script>
<script type="module">
import * as maplibregl from "maplibre-gl";
const map = new maplibregl.Map({
container: "map",
style: "https://api.mapmap.ai/tiles/uk/style.json?api_key=snk_…",
center: [-1.5, 52.6], // lon, lat
zoom: 6,
});
</script>
Loaded from a real https URL like this, MapLibre resolves its own worker from
import.meta.url and needs no extra setup.
Under a bundler, serve the worker yourself
MapLibre 6 ships its worker as a
separate ES module resolved from import.meta.url. Where the bundler cannot
rewrite that URL to something the browser can fetch as a module worker, and
Turbopack and Vite both fall into that class, no tiles arrive and the map is
blank. On maplibre-gl 6.9 and 6.10 nothing at all is logged: no console error,
no error event and no failed request. On 6.11.2 under Vite 8 the console does
show Error: Worker failed to load and a 404 for the worker file (checked 19
October 2026), but the map is just as blank. Serve both files yourself (the worker imports the shared chunk,
so serving the worker alone fails the same silent way) and point MapLibre at
your copy before the first map is constructed:
mkdir -p public/vendor/maplibre
cp node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs public/vendor/maplibre/
cp node_modules/maplibre-gl/dist/maplibre-gl-shared.mjs public/vendor/maplibre/
import * as maplibregl from "maplibre-gl";
maplibregl.setWorkerUrl("/vendor/maplibre/maplibre-gl-worker.mjs");
setWorkerUrl does not exist on MapLibre 5, so feature-detect it if you support
both majors. Vite needs this recipe too: MapLibre 6's defaultWorkerUrl()
builds the worker filename from a variable, so Vite emits no worker asset and
serves the SPA fallback HTML in its place. Bisected across six fresh installs on
17 September 2026: the documented Vite quickstart renders a blank map on
maplibre-gl 6.9.0, 6.9.1 and 6.10.0 (Vite 7 and 8, dev and build alike), and
5.24.0 works. The CDN page above is the one case that needs none of this,
because it loads MapLibre from a real https URL.
On MapLibre 5 that page works instead as a classic
<script src="…/dist/maplibre-gl.js"> tag reading the maplibregl global.
MapLibre 6 ships no such file, so the import-map form is the one to copy.
That is the whole integration. The style's source URL, the TileJSON it
resolves and the tile template under that all come back carrying the key from
the style URL, so MapLibre needs no transformRequest hook to put it back on.
Territory base maps include ocean and land at all zooms: the map factory stages the global sources (OSM ocean water polygons, Natural Earth, lake centrelines) into every territory build.
Server-side raster/PNG rendering is not offered; clients render the vector tiles themselves.
Hosted styles (the style API)
Styles are versioned and immutable: publishing writes a new version, and a versioned style URL can be cached forever.
| Method | Path | Auth | What it does |
|---|---|---|---|
| GET | /styles | key | List your own hosted styles (id, name, latest version, style URL) |
| GET | /styles/{id}.json | none | The latest compiled MapLibre style (short TTL) |
| GET | /styles/{id}@{version}.json | none | One immutable compiled version (cached forever) |
| GET | /styles/{id}/theme | none | The editable theme document behind the latest version (served no-store) |
| POST | /styles | key | Create a style as version 1. Metered, Standard class |
| POST | /styles/{id} | key | Publish the next immutable version. Metered, Standard class. Only the style's owner may publish over it |
Compiled-style and theme reads are public and unmetered, so a style can be
referenced by bare URL from a browser map client, which can attach no
credential. The listing is not: GET /styles needs your key and
returns only the styles that key created (or a sibling key of the same
account). A style you publish is yours: nobody else can list it, and
nobody else can publish a new version over its id.
A compiled style's basemap source reads the keyless tiles at
tiles.mapmap.ai, which serve mapmap.ai and local development only. For a
site of your own, request the style with your key,
GET /styles/{id}.json?api_key=snk_…: the style itself stays public, and
its basemap source comes back as the keyed gateway TileJSON
(/tiles/planet/tiles.json?api_key=snk_…), which carries the key on to
every tile. A style pinned to tiles on your own host is served as you
published it. The web SDK adds the key to a gateway style URL for you.
The hosted gateway's own house styles (MapMap Light and MapMap Dark) are
served from /tiles/{territory}/style.json and are unaffected by any of
this: they are not hosted styles you own, so they never appeared in your
listing and are still fetched by bare URL.
Create a style: POST /styles
| Field | Required | Default | Description |
|---|---|---|---|
name | yes | – | Human-readable name, 1–120 characters; its kebab-case slug seeds the style id |
theme | no | the default theme carrying name | A full theme document. To start from a drawn style rather than a slate, take a named base as your document, or let the create_style MCP tool seed it for you with base |
curl -fsS -X POST "$BASE/styles" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Midnight Fleet",
"theme": {
"name": "midnight-fleet",
"base": "dark",
"palette": { "water": "#0b2038" }
}
}'
201 Created:
{
"id": "midnight-fleet-9f3a2c",
"version": 1,
"style_url": "https://api.mapmap.ai/styles/midnight-fleet-9f3a2c@1.json",
"theme": { "name": "midnight-fleet", "base": "dark", "palette": { "water": "#0b2038" } }
}
The id is the name's slug plus six hex characters, so same-named styles never
collide. style_url is the immutable compiled style: drop it straight into a
MapLibre client.
Publish a new version: POST /styles/{id}
The body is not the theme itself: it is a wrapper with the full theme
document under "theme". POSTing a bare theme is a 400.
| Field | Required | Default | Description |
|---|---|---|---|
theme | yes | – | The complete theme document to publish as the next version (a full document, not a diff) |
Read-modify-write: fetch the current document from GET /styles/{id}/theme
(served no-store, so you never publish from a stale base), edit it, and POST
it back:
curl -fsS -X POST "$BASE/styles/midnight-fleet-9f3a2c" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"theme": {
"name": "midnight-fleet",
"base": "dark",
"palette": { "water": "#0d2742" }
}
}'
200 OK:
{
"id": "midnight-fleet-9f3a2c",
"version": 2,
"style_url": "https://api.mapmap.ai/styles/midnight-fleet-9f3a2c@2.json"
}
Existing versions are never modified; GET /styles/{id}.json starts serving
the new version within its 60-second TTL.
Errors
An invalid theme is a 422 (application/problem+json) whose problems list
names the offending slot or layer and the accepted values, so agents can
self-correct and resubmit:
{
"type": "urn:sn-gateway:problem:invalid-theme",
"title": "Theme validation failed",
"status": 422,
"detail": "theme validation failed: unknown palette slot \"watr\"; accepted slots: background, water, waterway, …",
"problems": [
"unknown palette slot \"watr\"; accepted slots: background, water, waterway, landcover, landuse, park, wood, grass, wetland, farmland, sand, rock, ice, building, aeroway, road, roadMajor, roadMotorway, path, rail, boundary, boundaryMinor, textPrimary, textSecondary, textHalo"
]
}
| Status | Type slug | When |
|---|---|---|
| 400 | bad-request | Malformed JSON body, or a name that is empty or over 120 characters |
| 401 | unauthorized | Missing, malformed or revoked API key on a metered endpoint |
| 404 | not-found | Unknown territory or style id, or tiles/styles/assets not staged on this deployment |
| 422 | invalid-theme | Theme failed validation; see the problems list |
| 429 | quota-exceeded / rate-limited | Monthly quota exhausted or per-minute limit hit |
The full error envelope, quota behaviour and the 402-vs-429 distinction are on the conventions page.
The theme document
A theme is a small JSON document the theme engine compiles into a full
MapLibre style-spec v8 style.json. The same engine runs in Studio, in the
gateway and in signed territory packages, so a theme renders identically
everywhere.
{
"name": "midnight-fleet",
"base": "dark",
"palette": { "water": "#0b2038", "roadMajor": "#8a6d3b" },
"layers": {
"building": { "visible": false },
"road-minor": { "paint": { "line-width": 2 }, "minzoom": 10 }
}
}
baseislightordark; the 25 palette slots below recolour the whole map. Colours are#rgb/#rrggbb/#rrggbbaaorrgb()/rgba()/hsl()/hsla().- Per-layer overrides:
visible,paint/layout(per-key merge),filter,minzoom/maxzoom. fonts.regularsets the label fontstack (default"MapMap Sans Regular"); keep it on a bundled fontstack or labels drop.fonts.labelsoptionally retypesets label types individually:{ "country", "city", "place", "road", "water", "poi", "housenumber" }, each a fontstack string; absent kinds inheritfonts.regular. Example:"fonts": { "regular": "MapMap Sans Regular", "labels": { "place": "MapMap Sans Bold", "water": "MapMap Sans Italic" } }. Publishing a fontstack this server does not host is a422naming the hosted stacks.glyphsandspriteoptionally override the compiled style's glyph and sprite URLs. Aspritethat is not a MapMap/sprite/spritesheet leaves the road-number shields out, since only that sheet has their blanks.territories.<id>.road_shieldsturns the road-number shields off or moves their minimum zoom per level:{ "visible": false }or{ "minzoom": { "motorway": 5, "national": 8, "regional": 11, "local": 13 } }(planetis the whole map on the planet archive).extra_layers: extra fully-formed MapLibre layers. Each uses theterritorysource with one of the source-layers the tiles emit (aerodrome_label,aeroway,boundary,building,housenumber,landcover,landuse,mountain_peak,park,place,poi,transportation,transportation_name,water,water_name,waterway), or one of the theme's ownsources. An optionalslot(bottom,middleortop) says where in the stack it goes; without one it goes inmiddle, above the base map and below labels. See Hosted styles with your own sources.sources: your own data by URL (GeoJSON, vector or raster tiles, PMTiles archives, an image), forextra_layersto draw. See Hosted styles with your own sources.extra: an opaque block the compiler ignores (capped at 256 KB serialised); Studio stores its navigation design and POI theming here.buildings_3d:truerenders buildings as 3D extrusions from OSM height data; see 3D buildings for where to (and not to) use it.terrain: 3D ground.{ "hillshade": true }is the usual value: hillshading is what makes relief legible from overhead, where the displacement alone is close to invisible. Also takesexaggeration(default1, clamped0–8),urlandencodingfor your own DEM. The compiler emits theraster-demsource, MapLibre'sterrainblock and, for hillshade, a second identical source. Sharing one betweenterrainand ahillshadelayer renders with artefacts. Shading is inserted below the first line or symbol layer, so it lands on the ground rather than on top of roads and building tops. The DEM carries its own attribution; the OpenStreetMap credit does not cover it. Studio exposes this under Land & landuse; the SDK equivalent isterrain: true.-
terrain.sea_reliefcolours the SEA FLOOR on its own, so the one hillshade layer's colours stop being shared by the land and the sea. Its depth ramp is set out further down, besideterrain.seabed. -
terrain.shadingstyles the relief. Every field is optional, and anything left unset is derived from yourlandcovercolour, so shading matches the map without being configured, and recolouring the ground moves the relief with it. MapLibre's own defaults are pure black and pure white, which suit no palette in particular: on a dark theme the highlights blow out, and on a warm palette black shadows read as dirt rather than relief.Field Default shadowlandcoverblended 50% toward blackslopes facing away from the light highlightlandcoverblended 50% toward whiteslopes facing the light accentlandcoverblended 20% toward blackridges and valleys intensity0.5strength, 0–1direction335degrees clockwise from north anchor"map"what the light is fixed to directiondefaults to 335 because that is the cartographic convention: light from the top-left. Lighting from below inverts the read and hills look like holes. Values wrap rather than clamp, so-25is a legitimate way to write335.anchordefaults to"map", which pins the light to the ground so hills shade the same however the map is rotated."viewport"is MapLibre's own default and glues the light to the screen, so every hill re-shades as the user turns, fine for a fixed-bearing view, disorienting in one the user can spin.
-
- Attribution is structural: every compiled style carries "© OpenStreetMap contributors © OpenMapTiles" on its tile source, and validation rejects any theme that tries to drop it.
Palette slots
All 25 slots, with the light/dark base defaults, in palette order.
Seven slots INHERIT rather than sit at a fixed default: roadMotorway
follows whatever roadMajor resolves to, and the six landcover-class slots
follow a mix of their parent slot, until you set each one explicitly. So
recolouring roadMajor alone moves motorways too (set both for the classic
orange-motorways-over-yellow-primaries look), and recolouring park alone
moves wood, grass and wetland with it. The Light/Dark columns below are
what those mixes resolve to at the default palette:
| Slot | Light | Dark |
|---|---|---|
background | #f4f2ee | #12161c |
water | #a8c8e8 | #1b2c40 |
waterway | #a8c8e8 | #1b2c40 |
landcover | #e3e8dd | #182029 |
landuse | #ece8e1 | #171e26 |
park | #c8e2bf | #1a2a1f |
wood | inherits park (#cfe4c7) | inherits park (#1a2822) |
grass | inherits park (#d7e5d0) | inherits park (#192525) |
wetland | inherits park (#dbe6d4) | inherits park (#192326) |
farmland | inherits landuse (#e7e8df) | inherits landuse (#181f28) |
sand | inherits building (#e2dfd1) | inherits building (#1f2730) |
rock | inherits textSecondary (#c5c9c1) | inherits textSecondary (#39414a) |
ice | #d9e3ec | #151e29 |
building | #e0d6c4 | #252d37 |
aeroway | #dcd9d2 | #232b34 |
road | #ffffff | #2b333d |
roadMajor | #f6d9a0 | #4a5461 |
roadMotorway | follows roadMajor | follows roadMajor |
path | #d9d4ca | #2a323c |
rail | #d0cbc2 | #262e37 |
boundary | #b9a6c9 | #4a3d57 |
boundaryMinor | #c9bcd6 | #3a3247 |
textPrimary | #3a3a3a | #d6dbe1 |
textSecondary | #6b6b6b | #9aa3ad |
textHalo | #ffffff | #12161c |
Styleable layer ids
Per-layer overrides target these 31 skeleton layer ids (paint order, first =
bottom): background, landcover, landuse, park, water, waterway,
aeroway, building, building-outline, rail, pedestrian-areas,
road-path, road-minor-casing, road-major-casing, road-minor,
road-major, road-bridge-casing, road-bridge, boundary-minor,
boundary, then road-oneway and the label layers: housenumber,
road-labels, water-name, water-name-line, poi-labels,
mountain-peak-labels, aerodrome-labels, place-labels, city-labels,
country-labels. Both boundary layers filter out maritime=1 features, so
territorial-water and EEZ borders are never drawn: they read as dotted lines
floating in open sea dividing nothing.
Per-class child layers
Six of those 31 are compiled into more than one layer. A paint property that
differs between two styles has to be constant per layer, or MapLibre drops
and re-requests every tile of the source the moment you switch between them,
so the class-tinted layers are split into one layer per class: a constant
colour and a filter on class each. The parent keeps its id and paints every
class the table below does not name.
| Parent | Child ids |
|---|---|
landcover | landcover-wood (wood, forest), -grass, -wetland, -farmland, -sand, -rock, -ice |
landuse | landuse-hospital, -school (school, university, college, kindergarten, library), -cemetery, -pitch (pitch, playground), -military, -retail (retail, commercial), -industrial (industrial, garages, bus_station), -residential (residential, suburb, neighbourhood, quarter), -stadium (stadium, track, theme_park, zoo), -railway, -quarry (quarry, dam) |
road-major | road-major-motorway |
road-major-casing | road-major-casing-motorway |
road-bridge | road-bridge-motorway, road-bridge-major (trunk, primary) |
water | water-ocean |
That is 31 groups plus 23 children: 54 layers in the default theme. Each child sits immediately above its parent, so paint order is unchanged.
Layer ids are a public contract, children included. They are stable, and
the list only ever gains ids, so anything reading a compiled style can rely
on them. You do not need to migrate anything: a theme.layers.<parent>
override is applied before the split and fans out over that parent's
children, so layers.landcover still recolours the whole of landcover and
layers.water still reaches the open sea, exactly as they did. Themes
address the 31 group ids; a child id in a layers block is an unknown id and
still a 422. To move one class on its own, set its palette slot.
landuse and landcover tint by the feature class so
hospitals, schools, cemeteries, military land, retail, grass and wood read
distinctly; the tints are blended from existing palette slots, and an
unrecognised class falls through to the parent's plain slot colour. Roads tagged
brunnel: tunnel are dimmed in place, while brunnel: bridge roads are
redrawn by road-bridge-casing/road-bridge above the flat network so an
overpass reads as crossing over what it spans. road-oneway draws one-way
direction arrows from z16 using a pinned Noto Sans Regular glyph, so it
needs no sprite.
road-minor-casing and road-major-casing draw the darker edge under each
road. Both casings paint beneath both road fills, so a minor road's casing
never crosses a major road at a junction. Their colours are derived from the
road/roadMajor slots blended toward textSecondary rather than being
separate palette slots, so overriding road restyles its casing too.
building-outline is derived from building the same way.
Agents get the same catalogue programmatically from the list_style_layers
MCP tool; an unknown id in a theme is a 422 echoing this list.
Named bases
You do not have to start from a blank palette. MapMap draws and contrast-tests ten first-party bases, and every one of them is reachable by name:
| Id | Group | What it is for |
|---|---|---|
light | base | The default light palette, a clean slate |
dark | base | The default dark palette, a clean slate |
streets | designed | The light flagship: warm paper, green landcover, blue water, white minors with amber majors, every label family on |
midnight | designed | The dark flagship: a blue-black ground with the road network as the brightest thing on the map and one warm motorway accent |
navigator-day | designed | Low-glare daylight guidance: muted land, dimmed water, prominent roads, minimal label noise |
navigator-night | designed | Night guidance: near-black and warm-biased, a grey-blue major network with an amber motorway spine |
fleet | designed | HGV and ADR planning: a near-monochrome operational ground so the vehicle-restriction overlay owns every saturated colour |
outdoor | designed | Walking and touring: landcover-led greens and sands, warm relief shading, emphasised peaks and parks, quiet roads |
dataviz | designed | A near-monochrome cool grey for your own data that keeps the road network, with one accent on the motorways |
backdrop | designed | The quietest base in the set: near-paper land, faint water, roads dropped to hairlines under labels only |
print | designed | Greyscale for paper: heavier road casings, serif labels a touch larger, no tint in the halo |
Where the ids work:
- Studio: the Theme section's base picker, stock bases above the divider and designed styles below.
- MCP:
create_styletakesbase: "outdoor", and merges anythemedocument you also send over it, so "Streets with darker water" is one call.list_style_layersreturns the table above, descriptions included. - Static map images:
style=outdooronGET /v1/static-map, alongsidestyle=lightandstyle=dark. - Web SDK:
baseTheme("outdoor")returns an editable document andstyleFromBase("outdoor")builds the style;MAPMAP_BASESis the list.
A base is a starting point, never a locked style: it loads as an ordinary theme document, and everything in it stays editable and publishable as your own style.
fleet carries one extra flag, restrictions: true, which every surface
that lists the catalogue reports. It means picking this base in Studio also
switches the vehicle-restriction overlay on,
because the palette exists to leave that overlay's five colours as the only
saturated thing on screen. The overlay is a website runtime layer, not
tile data, so it draws in Studio and on mapmap.ai/map and nowhere else:
style=fleet and baseTheme("fleet") give you the palette and no
restrictions. That is why the flag travels with the catalogue entry rather
than being something you could infer from the theme document.
The Navigator pair shipped as "Calm Cab Day" and "Calm Cab Night". The ids
calm-cab-day and calm-cab-night still resolve everywhere, so a saved
document or a link written against the old names keeps working.
One honest caveat for the SDK: @mapmap/maps carries a deliberately reduced
copy of the theme engine, with no terrain, no text_scale and no
per-label-kind fontstacks. Its Streets, Outdoor and Print therefore drop one
field each (the per-label fonts, the terrain block and the text scale
respectively), and each entry declares what it drops in notCompiledHere.
For the full document, point the map at a hosted style URL or use the static
map API, both of which run the canonical compiler.
3D buildings
Setting "buildings_3d": true in a theme (or the Studio toggle under
Layers → Buildings) adds a building-3d fill-extrusion layer: from zoom
15 the flat footprints rise into volumes using the OpenStreetMap
render_height/render_min_height attributes already in MapMap tiles. City
centres carry real surveyed heights; elsewhere buildings fall back to a
uniform default, so the effect is strongest where it matters most. Tilt the
map (pitch) to see it.
On the web you can also toggle it at runtime without recompiling the style:
const map = createMap({ container: "map", style: "light" });
map.setBuildings3d(true); // and false to return to flat footprints
Mobile: off by default, and never in navigation views. MapLibre Native
(the iOS/Android renderer) currently has prohibitive fill-extrusion memory
use at street-level zooms
(maplibre-native#4107;
900 MB+ at zoom 17, which iOS answers by killing the app). The native
SDKs ship two pure JSON transforms: styleWithBuildings3d(styleJson, enabled)
to opt a map browsing view in on hardware you've measured, and
styleForNavigation(styleJson) which strips 3D and should wrap any style
you load into a turn-by-turn view. The default will flip once MapLibre
Native lands level-of-detail culling for extrusions.
Colour theme (color_theme)
A colour theme grades every colour of the map through a 16x16x16 colour lookup table (LUT), the same idea as a Mapbox colour theme. The grade is baked into the compiled style, so every client that loads it draws the graded map with no runtime support: hosted style URLs, static maps, territory packages and MapMap Nav all get it for free.
{
"name": "night-fleet",
"color_theme": {
"lut": "<base64 of 12288 bytes>",
"size": 16,
"strength": 0.8,
"name": "night"
},
"layers": { "road-labels": { "use_theme": "none" } }
}
lutis 16 x 16 x 16 RGB triplets (12288 bytes, red fastest, then green, then blue), base64. Studio writes it for you from a preset or an uploaded Mapbox-format 256x16 PNG strip. The Studio presets are published as strips you can start from: night (turns a light map into a navy night map), night-dark (the gentler Night for a map that is already dark), mono, warm and cool. Studio picks the Night strip that matches the theme's base.sizeis 16;strength(0 to 1, default 1) blends the graded colour with the original, and at 0 the style compiles exactly as if the block were absent;nameis optional (a-z,0-9and-, up to 40).- Graded: every colour string in each layer's
paintandlayout(expressions andformattext colours included) and in the per-territory diffs, written as#rgb,#rgba,#rrggbb,#rrggbbaa,rgb(r,g,b)orrgba(r,g,b,a).hsl()and named colours are left as written, as are the rootlight,skyandsprite. "use_theme": "none"on a layer override keeps that layer's colours as written (Mapbox*-use-theme). An extra layer opts out with"metadata": { "mapmap:use-theme": "none" }.- The compiled style carries
metadata["mapmap:color-theme"]with the name, size, strength and the LUT's digest; the LUT bytes are not in it. - On the web you can also grade a live map at run time, raster and your
own layers included, with
map.setColorTheme(see the SDK docs).
Hosted styles with your own sources
A theme can carry your own data sources, so one hosted style URL draws the MapMap base map and your layers together, in any MapLibre client. MapMap stores the URL, never the data: you host the file or the tiles (a PMTiles archive on your own bucket is the simplest; see tiling your own data), and every viewer's map fetches it from you.
{
"name": "depots",
"sources": {
"depots": { "type": "geojson", "data": "https://cdn.example.com/depots.geojson",
"attribution": "(c) Example Ltd" },
"assets": { "type": "vector", "url": "pmtiles://https://r2.example.com/assets.pmtiles",
"attribution": "(c) Example Council 2026" },
"ortho": { "type": "raster", "tiles": ["https://tiles.example.com/{z}/{x}/{y}.png"],
"tileSize": 256 }
},
"extra_layers": [
{ "id": "ortho", "type": "raster", "source": "ortho", "slot": "bottom" },
{ "id": "assets", "type": "fill", "source": "assets", "source-layer": "assets",
"slot": "bottom", "paint": { "fill-color": "#c8843a", "fill-opacity": 0.5 } },
{ "id": "depots", "type": "circle", "source": "depots", "slot": "middle",
"paint": { "circle-radius": 6, "circle-color": "#0a7f5a" } }
]
}
Source types. geojson (data is a URL, never inline GeoJSON; optional
promoteId, cluster, clusterRadius, clusterMaxZoom, maxzoom),
vector and raster (exactly one of url, a TileJSON or
pmtiles://https:// archive, or tiles, a list of URL templates; optional
minzoom, maxzoom, bounds, and tileSize for raster, promoteId for
vector) and image (url and four coordinates, clockwise from top left).
Every source except image takes an attribution; credit your data there
and it shows in the map's attribution with the OpenStreetMap credit.
The rules, enforced when you publish.
- Every URL is
https://(pmtiles://https://for an archiveurl), with no user name or password, no IP address and nolocalhost, at most 2,048 characters. - At most 15 sources per theme, and at most 8 of them GeoJSON (each is a whole-file download for every viewer; tile anything large as PMTiles).
- Source ids are 1 to 40 characters of
a-z,0-9and-.territory,terrain-dem,hillshade-dem,color-relief-dem,seabed-demandmm-user-markersare the compiler's own and cannot be used. - A layer must suit its source:
rasterlayers forrasterandimagesources;fill,line,symbol,circle,heatmaporfill-extrusionforgeojsonandvector. A layer on avectorsource needs itssource-layer; a layer on any other source must not have one. attributionmay be plain text and<a href="https://...">links only.
A publish that breaks a rule is a 422 naming the exact field, for example
sources.depots.data: must be an https:// URL.
Slots. slot puts a layer at a named place in the MapMap stack, so you
never need a base map layer id:
| Slot | Where it draws | Directly below |
|---|---|---|
bottom | above land, landuse and water; below roads and buildings | building |
middle (default) | above roads and 3D buildings; below every label | road-oneway |
top | above POI labels; below place, city and country labels | place-labels |
Layers in the same slot keep the order they have in extra_layers. The web
SDK uses the same names at runtime (map.slot("bottom"),
addPersistentLayer(layer, { slot })), resolved from the same table.
The static-map API never fetches your data. A server-side render loading
a URL you chose is a request from our network to yours, so /v1/static-map
draws a hosted style without its customer sources and their layers, and says
so: the image response carries X-MapMap-Customer-Sources-Skipped with the
ids it left out. To show your data, draw the style in the browser with the
web SDK or any MapLibre client.
Studio
Studio is the browser editor over the same theme engine. The map fills the screen, and a section rail (a tab row along the bottom on a phone) switches between eight sections: Theme (pick the base and recolour the palette, including colour themes), Layers (toggle layers and adjust colours, widths and opacities per layer, and colour the POI categories), Typography, Environment (the relief light, projection, sky and globe), Navigation (below), Markers (place coloured glyph pins, dots or small custom images with labels, saved with the style so the SDKs draw them), Data (your own GeoJSON, PMTiles and raster sources, see hosted styles with your own sources), and Publish. Your working theme autosaves in the browser.
The Theme section's picker offers the ten first-party named bases as starting points: the plain Light and Dark head the list, and the designed styles (Streets, Midnight, Navigator Day, Navigator Night, Fleet, Outdoor, Dataviz, Backdrop, Print) sit below the divider. They are the same bases the API, the MCP tools and the SDK take by id, so a design you pick here is one an agent can ask for by name.
Both Navigator bases are tuned so the default route line stays the highest-contrast element on screen. A base loads into the editor as an ordinary theme document (your navigation design is kept), so everything remains editable and publishable as your own style.
Your own data (the Data section)
Studio's Data section puts your own data on the map you are designing, in two ways:
- Drop a file: GeoJSON (
.geojson,.json) or CSV with latitude and longitude columns (lat/latitude/yandlon/lng/longitude/x; UTF-8 only), up to 2 MB and 50,000 features. The file stays in your browser: nothing is uploaded, it previews here only, and it is not published. To publish it, host the file (or tile it to PMTiles with@mapmap/tile) and add it by URL. - Paste a URL: a GeoJSON file, a PMTiles archive (give the name of the
layer inside it) or an XYZ raster template with
{z},{x}and{y}, onhttps://. A URL layer publishes with the theme as one of its sources: the hosted style stores the URL, never the data, and every viewer's map fetches it from your host, which must allow cross-origin requests. The same URL rules apply, and the panel checks them before you publish.
Each layer has a draw mode (points, lines, polygons, clusters, a heatmap or
3D extrusion), a slot (bottom, middle
or top), a colour by one value, by a category property or along a number,
an opacity and a credit line. Copy code gives the @mapmap/maps
DataLayer call that draws the same layer in your app; the preview is drawn
with that same class. The Publish section says what goes and what stays, for
example "2 sources by URL; 1 layer stays in this browser and is not
published". Data layers are part of the theme document (extra.data holds
their design; a dropped file is named there, its features never are), so
autosave and undo cover them.
Vehicle restrictions (Fleet)
Studio and the map at mapmap.ai/map can draw the OpenStreetMap ways that carry a vehicle restriction: a height, weight, width or length limit, an HGV ban or access-only rule, a blanket hazmat ban, or an ADR tunnel category. Five colours, one per class, plus a marker on every signed height of 5.03 m (16 ft 6 in) or less. Click a road for the signed values exactly as OSM has them and a link to the way.
Three limits, stated plainly because they decide whether the layer is useful to you:
- It is an overlay, not tile data. The render tiles use the OpenMapTiles schema, which carries no restriction attributes, so the overlay is installed around the compiled style by Studio and /map. A hosted style URL, the static-map API and the SDKs do not carry it. A Fleet style you publish is the palette and typography only. Moving the attributes into the tile schema is planned work, and it is what would make the style work everywhere, offline packages included.
- Coverage is Great Britain and Ireland. Outside that area there is no data, which is not the same as no restrictions.
- OSM restriction tagging is incomplete. An unmarked road is a road nobody has surveyed, never a road with no restriction. This layer is not a clearance survey, and it is not a substitute for one.
If you need restrictions applied to a ROUTE rather than drawn on a map, that is a different thing and it does work everywhere: the routing API takes truck dimensions and an ADR declaration and costs the route against them. See the truck and ADR guide.
From the Publish section's "Use this style" panel: paste your snk_ key and
publish: the first publish creates the hosted style (POST /styles), and
each one after publishes the next immutable version (POST /styles/{id}).
Drop the returned style URL into @mapmap/maps or raw MapLibre, or download
the compiled style.json instead. Agents get the same powers through the MCP
style tools (list_style_layers, get_style, create_style, set_palette,
set_layer_paint); see the MCP guide.
Navigation design
Studio's Navigation panel designs the turn-by-turn look (route line,
current-position puck and banner instruction), previewed live on the map.
The result is a small design-token block stored under extra.nav in the
theme JSON Studio downloads and copies:
{
"extra": {
"nav": {
"version": 1,
"route": { "color": "#ff7a1f", "width": 4.5, "opacity": 1, "casingColor": "#8a3d00" },
"puck": { "color": "#1f6fff", "size": 14, "headingArrow": true,
"imageUrl": "data:image/png;base64,…" },
"banner": { "background": "#101418", "textColor": "#ffffff", "fontSize": 16, "showLanes": true,
"padding": 10, "cornerRadius": 10, "maxWidth": 340 },
"camera": { "pitch": 60, "zoom": 17.5, "speedMps": 12 },
"alerts": {
"background": "#101418", "textColor": "#ffffff", "outlineColor": "#ffffff",
"cornerRadius": 10, "outlineWidth": 1.5,
"escalatedBackground": "#c0392b", "escalatedTextColor": "#ffffff",
"iconSet": "european", "mapIconSize": 1, "mapMinZoom": 12,
"alertSound": "cameraCalm", "escalatedSound": "cameraUrgent",
"leadDistance": "standard", "chipPosition": "aboveSpeed",
"corridorColor": "#e05c6c", "corridorOpacity": 0.55,
"kinds": {
"fixed": { "showOnMap": true, "showOnMapWhileNavigating": true,
"alertWhileDriving": true, "showInRoutePreview": true,
"alertMode": "always", "audioMode": "earcon" }
}
}
}
}
}
-
terrain.seabed(0..1, default absent) reveals the seabed through the sea whilehillshadeis on: the DEM carries bathymetry, so lowering ocean water's opacity lets the relief show through, tinted by the water colour. Onlyclass=oceanwater is affected (lakes and rivers stay opaque), and it composes with a per-layer water opacity override rather than replacing it. The compiledwaterlayer carries the reveal on its ocean branch, at every zoom:json["match", ["get", "class"], ["ocean"], 0.3, 1]Turning the dial up also emits an opaque
water-sea-basefill: the same ocean polygons, the same colour, at the bottom of the relief sandwich, so the draw order under the sea is:textwater-sea-base → seabed-relief → hillshade → waterThat floor is not optional. The reveal makes the ocean fill translucent, and both relief layers are transparent wherever the DEM has no sea floor to draw, so without it a translucent sea shows the
backgroundlayer, i.e. the theme's LAND colour. Its second job is keeping the sea a sea when the data runs out (below).Where the seabed actually reads. The default DEM (Mapzen terrarium on AWS Open Data) carries bathymetry only to tile zoom 10. Above that, open-ocean tiles are a uniform 0 m placeholder. Two things follow, and they are different:
- the depth colour holds at every zoom, where
sea_reliefis set.seabed-reliefreads its ownseabed-demsource, capped at zoom 10 (see below), so past that zoom MapLibre overzooms the last real bathymetry rather than mixing it with the placeholders. Zoom in on the Ligurian Sea and the water stays the same deep blue it was at zoom 10. Withoutsea_reliefthere is no depth ramp to hold: the sea is the water colour at every zoom, as it always was. - the shading needs data it does not have. There is one hillshade, it reads the DEM at full resolution because it shades the land, and a uniform 0 m tile has no slope to shade. So from about map zoom 11 the sea loses its relief while its colour stays put.
Both statements are about the compiled style, which paints the sea from the tiles' own water polygons, so they hold wherever those tiles have data. The Studio preview additionally paints the sea OUTSIDE a partial archive's coverage from the elevation model, and that stand-in fades out over map zooms 11.5 to 12.5 as the tiles' own water polygons take the veil back; beyond coverage there are none, so from zoom 12.5 the water out there carries no veil and reads at full strength. That is a preview artefact of a partial archive only. Production Studio runs on planet tiles, where "outside coverage" does not exist, and a published style never carries the stand-in at all.
Land tiles are unaffected and keep their full resolution. Shelf seas (the Channel, the North Sea) keep real depths a few zooms further. Point
terrain.urlat a DEM with deeper bathymetric coverage and both the colour and the relief follow it up the zooms with no style change; raiseSEABED_DEM_MAXZOOMwith it.Corrupt DEM tiles, and what the Studio preview does about them. Mapzen's 0 m placeholder is a 757-byte PNG whose every pixel decodes to exactly 0 m. A small number of tiles in that same size class are corrupt, in two measured flavours: a 270-byte tile whose every pixel is (0,0,0), which decodes to −32,768 m; and a 0 m placeholder with a few full-height one-pixel columns of garbage baked in:
13/3413/3100.pngis 96.9% exactly 0 m with eight columns reading −2,590 to −14,840 m. Either way the tile carries a cliff of thousands of metres across one DEM pixel, and the hillshade's slope operator lights it as a hard bright/dark line across open sea. The −32,768 m flavour spreads further: MapLibre copies each tile's edge row into its neighbours' 1-pixel border pad, so the four neighbours get the cliff too. Known instances: zoom 14 near 6.95°E and 8.01°E (out of reach behind the render DEM'smaxzoom: 13) and a patch at 30.000°W, 40°N that shows from zoom 10 to 13.The default DEM is served through a sanitising proxy, and that is the repair. The compiled default
terrain.urlishttps://mapmap.ai/terrain/{z}/{x}/{y}.png, not the archive's own URL. That route fetches the Mapzen tile server-side and, when a tile under 2 KB decodes as one of the two corrupt placeholders, serves a clean 0 m tile in its place. Everything else passes through byte for byte, and a real tile is never even decoded: the smallest tile carrying genuine content anywhere in a 126-tile sweep was 5,267 bytes, well clear of the 2 KB ceiling. Responses areCache-Control: public, max-age=31536000, immutableandAccess-Control-Allow-Origin: *, so a published style running on your own origin gets them from the CDN edge like any other tile. An upstream 404 or 5xx passes its status straight through.This applies to published and exported styles, not just the preview: a downloaded
style.jsoncarries the proxy URL, so a map rendered in your own MapLibre with no MapMap code in it still gets the repair.The DEM is still Mapzen's, and its attribution is contractual: every compiled style carries the credit "Terrain data Mapzen, AWS Open Data" (the first two words link to the AWS registry page) on the source, and serving the bytes from our origin does not change that. Keep the credit visible.
Self-hosters. Setting
terrain.urlopts out of the proxy entirely: your DEM, your URL, fetched directly. If you pointterrain.urlat raw Mapzen, the Studio preview still repairs it client-side through a MapLibre custom protocol (one request per tile, no caching of its own), but an exported style with that URL in it carries the defect: that is what the default exists to avoid. - the depth colour holds at every zoom, where
-
terrain.sea_relief(default absent) gives the SEA FLOOR its own colour, independent of the land's relief shading. There is only ever onehillshadelayer, and it shades the land and the seabed with the same shadow/highlight colours, so a green land turns the sea green too, which is the thing this fixes. Setshallowanddeep(both required; a block missing either is dropped whole) and optionallyopacity(0..1, default0.8):json"terrain": { "hillshade": true, "seabed": 0.7, "sea_relief": { "shallow": "#7fb2d9", "deep": "#123b66", "opacity": 0.8 } }The compiler emits a MapLibre
color-relieflayer,seabed-relief, inserted directly BELOW the hillshade and belowwater: colour first, shading over it, the same order the land is drawn in (landcover fill, then hillshade), so the depth ramp owns the sea's hue while the shading keeps its full contrast. It rides a thirdraster-demsource,seabed-dem: the same DEM ashillshade-dem, capped atmaxzoom: 10(see "The seabed ramp reads a z10 DEM" below).color-reliefcolours the DEM by height, so the ramp runs on["elevation"]in metres rather than on zoom: a real depth ramp, deep water reading differently from a continental shelf:json["interpolate", ["linear"], ["elevation"], -6000, "#123b66", -20, "#7fb2d9", -20, "rgba(0,0,0,0)", 9000, "rgba(0,0,0,0)"]Everything above −20 m is fully transparent, which is what makes this a sea-only colour: land keeps whatever the hillshade and the landcover give it. The cut-off is −20 m rather than 0 m so the DEM's own vertical error cannot bleed a blue rim along every coastline; the known cost is that genuine land below −20 m (the Dutch polders, the floor of Death Valley) takes a faint tint at the shallow end of the ramp.
It pairs with
terrain.seabed, which decides how much of it you can see: the water fill still paints on top, so withseabedat 0 the sea is opaque and the ramp is invisible wherever there is a water polygon to paint it, which in a published style is everywhere the tiles cover (see "Where the seabed actually reads" above for the preview's out-of-coverage exception). The layer itself is emitted whether or nothillshadeis on (it is a colour of its own, not a reveal of something underneath) and requires terrain.There is only ever ONE hillshade, and its shadow and highlight colours belong to the land, so a green land shadow tints the sea's shadows green-blue. A second hillshade in the sea's own colours is not possible: a raster layer cannot be clipped to the ocean polygons, and a second one above
waterwould double-shade the land. The depth ramp underneath owning the hue is the answer to that, not a workaround for it. -
route: line colour, width (px at mid zoom), opacity and casing colour; the defaults are the web SDKRouteLayerlook. -
puck: current-position colour, diameter in px, and whether the heading arrow is drawn.imageUrl(optional since v1) sets a custom puck image: anhttps:ordata:URI (PNG/SVG) that replaces the built-in dot and arrow;sizestill scales it, and it rotates to the heading. -
banner: background/text colours, primary text size in px, and whether the lane diagram row is shown, matching the web SDKGuidanceBannerconventions.padding(px, 6–24, default 10),cornerRadius(px, 0–24, default 10),maxWidth(px, 200–520, default 340) andheight(px, 40–120, unset = auto content height) are optional since v1; the block stays"version": 1and themes saved without them load with the defaults. -
alerts(optional since v1): the safety-camera alert design, written by Studio's "Safety alerts" section. Chip tokens arebackground,textColor,outlineColor,cornerRadius(px, 0–24) andoutlineWidth(px, 0–4);escalatedBackgroundandescalatedTextColorstyle the over-the-limit state.iconSetis one ofeuropean,us,minimalorbrand, with optional per-kindiconUrlOverrides(https:ordata:URIs only).alertSoundandescalatedSoundpick the two earcons from the built-in library (cameraCalm,cameraUrgent,overspeedSoft,zoneEnter,zoneClear), each with an optionalalertSoundUrl/escalatedSoundUrloverride under the samehttps:ordata:rule.mapIconSize(0.75–1.5 of the default pin ramp) andmapMinZoom(9–14) control the map pins,corridorColorandcorridorOpacity(0–1) tint the average-speed corridor on the route line,leadDistanceis one ofshort,standardorlong(150 m, 250 m and 400 m floors), andchipPositionis one ofaboveSpeed,topLeadingortopTrailing.kindscarries one entry per camera kind (fixed,average,red_light,mobile_site,unknown), each withshowOnMap,showOnMapWhileNavigating,alertWhileDrivingandshowInRoutePreviewbooleans, analertModeofalways,whenSpeedingornever, and anaudioModeofoff,earconorearcon_and_speech. Themes saved without the block load unchanged and stay"version": 1; a block that is present always parses complete, escalated tokens included.showOnMapandshowOnMapWhileNavigatingare separate on purpose: browsing the map and driving it are different decisions, and the market warns with or without a planned route, so map display is not gated behind active guidance. A theme written before the split carries onlyshowOnMap, and its navigating value reads astrue.There are two sounds, not ten. The calm earcon and its escalated variant are the whole audible vocabulary: which camera it is travels in the icon and in speech, and how urgent it is travels in the timbre, so a driver is never asked to learn one chime per camera kind. Set a kind's
audioModetooffif you want it silent. The average-speed corridor keeps its own entry and exit pair, which marks a boundary rather than a point alert.The built-in earcons are served from
https://mapmap.ai/earcons/(earcon-camera-calm.mp3,earcon-camera-urgent.mp3,earcon-overspeed-soft.mp3,earcon-zone-enter.mp3,earcon-zone-clear.mp3). They are public and unmetered; self-host them by passing your own base URL tonavAlertSoundUrl.Two behaviours are deliberate rather than configurable. The visual chip is unconditional and calm while audio is the escalation channel, so a driver within the limit still sees the alert and only hears it when
alertModesays so. And the chip position is a fixed set of slots rather than free coordinates, so an alert can never cover the speed or the limit, which are the two numbers it is asking the driver to act on.Jurisdiction policy is server side and is not styleable. Whether camera positions may be returned at all is decided per country by the gateway before any camera leaves it. This block designs how a permitted alert looks; it cannot widen what is permitted, and a theme carrying an alert design still shows nothing in a territory whose policy omits cameras.
-
camera(optional since v1, whole block and each field): the drive camera Studio's "Drive the route" demo uses and the web SDK'sNavigationCamerareads as its defaults.pitch(degrees, 0–85, default 60),zoom(14–20, default 17.5) andspeedMps(drive speed in metres per second, 2–40, default 12). ExplicitNavigationCameraoptions still win: option > design > built-in. Themes saved without the block load unchanged.
The block never enters the compiled style.json (the compiler ignores it
entirely), but it does travel with the theme document, hosted ones
included: Studio's publish sends extra with the theme (whenever the
design differs from the defaults), the gateway stores it verbatim with that
version, capped at 256 KB serialised, and serves it back from
GET /styles/{id}/theme. So teams consuming a style via the API get the
navigation design too. Pass a downloaded theme file to the web SDK's
createMap, which parses extra.nav into map.navDesign and applies it
across the navigation UI:
import { createMap, RouteLayer, GuidanceBanner, PositionPuck } from "@mapmap/maps";
import theme from "./midnight-fleet.theme.json"; // downloaded from Studio
const map = createMap({ container: "map", apiKey: "snk_…", style: theme });
await map.whenReady();
const routes = new RouteLayer(map); // route line + casing from extra.nav.route
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);
const puck = new PositionPuck(map); // dot + arrow or custom image
puck.setLocation({ lat: 51.5074, lon: -0.1278 }, 45);
For a vehicle rather than a dot, AvatarPuck from @mapmap/maps/avatars
draws one of MapMap's own cars in its place; see
Vehicle avatars.
…or fetch the design straight from a hosted style's theme endpoint (public, uncached, always the latest publish):
import { navDesignFromThemeUrl } from "@mapmap/maps";
const design = await navDesignFromThemeUrl(
"https://api.mapmap.ai/styles/midnight-fleet-a1b2c3/theme",
);
Parsing is lenient and clamped exactly as Studio previews it; without a nav block everything keeps the SDK's built-in look. See SDKs for the full reference.
POI theming
The POIs panel (Layers section) colours the eight POI categories the
MapMap maps group OpenMapTiles poi classes into: food_drink,
shopping, transport, lodging, health, culture_leisure,
education and services (the fallback for any class not grouped
elsewhere). Per category you can recolour the dot drawn under the
label and the label text; both preview live. The result is a block
under extra.poi, alongside the navigation design:
{
"extra": {
"poi": {
"version": 1,
"categories": {
"food_drink": { "color": "#ff6b35", "textColor": "#b34700" },
"transport": { "color": "#0aa8a8" }
}
}
}
}
Both fields are optional per category: an absent color keeps the
built-in dot colour, an absent textColor keeps the theme's
textSecondary label colour. Studio omits the whole block while
everything is at the defaults, so existing themes are untouched.
Like the navigation design, the block never enters the compiled
style.json: it travels with the theme document, publishes with it, and
comes back from GET /styles/{id}/theme. The MapMap website maps
(Studio's preview included) apply it to their category dots and label
text automatically. In the web SDK, a theme document passed to
createMap parses into map.poiDesign, and the label-text part applies
to any live map:
import { createMap, applyPoiDesign, poiDesignFromThemeUrl } from "@mapmap/maps";
import theme from "./midnight-fleet.theme.json"; // downloaded from Studio
const map = createMap({ container: "map", style: theme });
await map.whenReady();
if (map.poiDesign) applyPoiDesign(map.map, map.poiDesign);
// …or from a hosted style's theme endpoint:
const design = await poiDesignFromThemeUrl(
"https://api.mapmap.ai/styles/midnight-fleet-a1b2c3/theme",
);
The SDK's default styles label POIs as text (no dots), so applyPoiDesign
covers the text colours; the coloured dot icons are drawn by the MapMap
website maps today, and packaged sprite icons are planned as part of
marker packs. Parsing is lenient: unknown categories and invalid colours
are dropped, never fatal.
Label languages
Tiles carry OpenStreetMap name:* translations, 70+ languages on populated
tiles, alongside each place's local name. The web SDK's buildStyle takes a
labelLanguage option: an ISO code ("de", "ja", "ar", …) renders labels
in that language with local-name fallback, "local" shows every place in its
own language, and the default is English-first. Pass the built style to
createMap:
import { createMap, buildStyle } from "@mapmap/maps";
const map = createMap({
container: "map",
apiKey: "snk_…",
style: buildStyle({ theme: "dark", labelLanguage: "ja" }),
});
See it live on the map: the language picker offers English, local
names and fifteen more. Every script renders: the bundled Noto fontstacks are
the OpenMapTiles merged builds, covering Arabic, Hebrew, Thai, Myanmar,
Khmer, the Indic scripts, CJK and more. On MapLibre GL JS 6.9 and later
Arabic and Hebrew shaping is built in: there is nothing to load, and
setRTLTextPlugin / getRTLTextPluginStatus are deprecated. On MapLibre 5
you still need the plugin: call maplibregl.setRTLTextPlugin(…) once in
your app.
Fonts and sprites
Compiled styles reference glyphs and sprites served by the gateway (public, unmetered, immutable caching):
GET /fonts/{fontstack}/{range}.pbf: SDF glyph ranges for the bundled fontstacks:MapMap Sans Regular,Medium,SemiBold,Bold,BlackandItalic(all six composited with the merged Noto stacks so every script renders),Noto Sans Regular(plus Bold and Italic), and a Regular and a Bold of each ofBarlow,Fira Sans,IBM Plex Sans,Inter,Lato,Montserrat,Noto Serif,Nunito,Open Sans,Rubik,Source Sans 3andWork Sans(soMontserrat Bold,Inter Boldand the rest), all SIL Open Font Licence, static instances. Keep every fontstack the theme names on one of these or those labels drop: an unknown fontstack answers 404. Families added in a release reach the hosted fonts endpoint with the next assets deploy; Studio previews them immediately.GET /sprite/sprite[@2x].json|png: the first-party sprite sheet (marker, dot, arrow, lane diagrams, safety cameras, tunnel-restricted, and the road-shield blanksshield-<template>, one per country tier, which carrycontent,stretchX,stretchY,textFitWidthandtextFitHeightforicon-text-fit, the two text fit keys always as a pair, which MapLibre Native requires;shieldSpriteNamein@mapmap/maps/road-signsnames them). Every compiled style that names nospriteof its own points here, because itsroad-shieldslayer draws from these blanks (see road-number shields on the map).GET /sprite/marker-sprite[@2x].json|png: the SDF marker sheet (22 entries:marker-pinand themarker-glyph-<id>icons). A compiled style points itsspritehere only when the theme carries anextra.markersblock and sets nospriteof its own; a customspritemust carry the same entries or the baked markers have nothing to draw.
Status, honestly: compiled styles default their glyph URL to
https://fonts.mapmap.ai/{fontstack}/{range}.pbf, which is live and serving.
On self-hosted deployments, set the
theme's glyphs field to your own gateway's route, e.g.
$BASE/fonts/{fontstack}/{range}.pbf. Using your own hand-written style
instead of a compiled theme? Point its glyphs and sprite fields at these
same routes.
On self-hosted deployments these routes serve when SN_MAP_ASSETS_DIR is
set; tiles need SN_TILES_DIR and hosted styles need SN_STYLES_DIR (see
self-hosting).
Web SDK quickstart
Availability: @mapmap/maps is live on
npm.
@mapmap/maps wraps MapLibre GL with the MapMap style, the pmtiles
protocol and typed routing helpers:
npm install @mapmap/maps maplibre-gl pmtiles
Under Vite or Turbopack, also serve MapLibre's worker from your app, or the map stays blank: two files and one call.
As with raw MapLibre, the page needs a container element with an explicit
height, <div id="map" style="height: 100vh"></div>, or the map renders
blank. The SDK console.errors when it detects this and the other silent
blank-map causes; see troubleshooting.
import { createMap, RouteLayer } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";
const map = createMap({
container: "map",
apiKey: "snk_…",
style: "light", // "light" | "dark" | a Studio theme | a style URL
center: [-1.5, 52.6], // lon, lat
zoom: 6,
});
await map.whenReady();
const routes = new RouteLayer(map);
await routes.route(
{ lng: -0.1278, lat: 51.5074 },
{ lng: -1.5106, lat: 52.4081 },
{ profile: "truck", truck: { heightM: 4.0, weightT: 40, hazmat: true, tunnelCode: "C" } },
);
createMap is the entry point used throughout these docs and by Studio's
publish snippet. It is a functional alias for the MapMapMap class, so
new MapMapMap({ … }) takes the same options if you prefer the constructor
form.
The truck options cover ADR (the European agreement on carriage of
dangerous goods by road; tunnel codes B–E restrict which tunnels a hazmat
load may use); the full tunnel-code table is on the
conventions page. The style option accepts a Studio
theme document directly, so a style built in Studio drops straight into your
app. Full SDK reference: SDKs.
Two routes on one map (comparison)
A car-vs-truck (or before-vs-after) comparison is two RouteLayers with
distinct ids; recolour the second with setLineStyle, no raw MapLibre
sources or layers, and no full design document, needed:
import { RouteLayer } from "@mapmap/maps";
const car = new RouteLayer(map, { id: "route-car" });
const truck = new RouteLayer(map, { id: "route-truck" });
truck.setLineStyle({ color: "#e07b39" }); // dash and casingOpacity too
const a = { lng: -0.1278, lat: 51.5074 };
const b = { lng: -1.5106, lat: 52.4081 };
const [carRoute, truckRoute] = await Promise.all([
car.route(a, b),
truck.route(a, b, { profile: "truck", truck: { heightM: 4.5 } }),
]);
// carRoute/truckRoute carry distanceM and durationS for a comparison panel.
setLineStyle survives style reloads (theme swaps) and {} clears it back
to the design. If you would rather fetch the second route yourself, the pure
helpers do the assembly and parsing, but note buildRouteUrl does not
embed the API key in the URL; add the Authorization header on manual
fetches (RouteLayer does this internally):
import { buildRouteUrl, parseOsrmRoute } from "@mapmap/maps";
const url = buildRouteUrl("https://api.mapmap.ai", "truck", [a, b], { heightM: 4.5 });
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const parsed = parseOsrmRoute(await res.json());
truck.drawGeometry(parsed.geometry.coordinates);
Exporting the map as an image (design-tool plugins, PNG downloads,
server-side thumbnails)? Two gotchas: MapLibre 5 moved
preserveDrawingBuffer into canvasContextAttributes, and captures should
render offscreen at the target size rather than screenshot the interactive
map (keeping the drawing buffer costs a full-frame copy on every frame, so
the live map should not pay it). A throwaway map in a hidden container of
the output size does both:
const container = document.createElement("div");
container.style.cssText = "position:absolute;left:-10000px;width:1200px;height:630px";
document.body.append(container);
const shot = createMap({
container,
style: "light",
center: [-0.1276, 51.5072],
zoom: 12,
mapOptions: {
interactive: false,
fadeDuration: 0, // otherwise labels can be caught mid-fade
canvasContextAttributes: { preserveDrawingBuffer: true },
},
});
await shot.whenReady();
await new Promise((resolve) => shot.map.once("idle", resolve));
const png = shot.map.getCanvas().toDataURL("image/png");
shot.map.remove();
container.remove();
The attribution control and the MapMap mark are DOM elements over the canvas, so they are not in the pixels: stamp the credit onto the exported image yourself.
3D terrain
Availability: shipped in @mapmap/maps 0.10.0.
Any MapMap map can drape itself over real elevation and shade the relief:
const map = createMap({
container: "map",
apiKey: "snk_…",
terrain: true, // sensible defaults
});
// or tuned:
createMap({
container: "map",
terrain: { exaggeration: 1.4, hillshade: true },
});
// and off again at runtime:
map.setTerrain(false);
| Option | Default | What it does |
|---|---|---|
exaggeration | 1 | Vertical scale. 1 is true-to-life; 1.2–1.5 reads better on gentle terrain. Clamped to 0–8 |
hillshade | false | Adds relief shading that stays legible even looking straight down |
url | https://mapmap.ai/terrain/{z}/{x}/{y}.png (Mapzen terrarium from AWS Open Data, through MapMap's sanitising proxy) | DEM tile template. Self-hosters point this at their own terrarium tiles |
encoding | "terrarium" | DEM encoding ("terrarium" or "mapbox") |
maxzoom | 15 | Max zoom of the DEM tile set |
tileSize | 256 | DEM tile size in pixels |
attribution | The Mapzen / AWS Open Data credit | Attribution for the DEM source; keep a credit for whatever DEM you use |
The default DEM is the same dataset the gateway samples for
POST /elevation. A rendered mountain and a queried
one agree with each other, which matters the moment you draw elevation
numbers over a 3D map.
The SDK carries the MapLibre terrain lifecycle for you: terrain does not survive a style swap (the SDK re-applies it on every style load, so theme switching just works), hillshading needs its own DEM source rather than sharing the terrain's (sharing produces rendering artefacts), and the DEM's attribution rides alongside the OSM credit automatically.
Two things to know when building on top:
- Terrain displaces the ground by absolute elevation.
map.queryTerrainElevation(lngLat)returns metres above sea level, not height above some local zero. A custom layer that draws in a local frame must add the terrain elevation at its anchor, or every distance-derived effect computes from a camera underground. - Mind the vertical datum when overlaying your own data. GNSS/survey heights are usually ellipsoidal; DEMs are orthometric (geoid). The difference is tens of metres in most of Europe, enough to sink a building into a hillside. Convert before you overlay; the elevation API reports orthometric heights.
Landmark directions
Turn instructions on the map can reference a recognisable place instead of a bare street name: "Turn right just after KFC". The landmarks come from MapMap's first-party POI index, queried around each manoeuvre point on the selected route.
The feature is confidence-gated, because a wrong landmark is worse than none. A place only replaces the street name when it clears a salience bar (petrol stations, supermarkets, stations, places of worship, hospitals, pubs and department stores score highest at 5.0 against a bar of 4.0; fast food sits at 3.0 and only clears it with the brand bonus), sits within 40 m of the junction, and shows no signs of staleness: places whose records carry closed or disused markers are never referenced. When no candidate clears the bar, the instruction keeps the street name.
Toggle it with "Landmark directions" under Options in the directions panel; the choice is remembered on your device. Voice guidance itself already ships in the SDKs; what is not wired up yet is landmark phrasing inside it, so a spoken instruction still names the street.
Report a map issue
Spotted a map error: a road that doesn't exist, a vehicle restriction
that's wrong, a shop that closed years ago? Use the flag button on
the map: click the map to pin the exact spot, pick a category,
optionally add a note and an email for follow-up, and submit. API users
can file the same report directly with POST /map-issues (one
Standard-class call; the request body is
{lat, lon, category, note?, contact?}; see /openapi.json on your
gateway for the accepted categories).
Every report is picked up by an agent, which investigates it against OpenStreetMap ground truth, our own geocoder and satellite imagery, and publishes its diagnosis on the public Map Health board. Reports are answered and triaged in minutes, and many fixes go live the same hour.
Reports never edit the map directly, and neither does the agent. A human-verified fix ships in two directions: immediately, as a data override on our own routing gateways, and upstream as a proper OpenStreetMap contribution, so your report improves the map for everyone, not just MapMap users.
Troubleshooting a blank map
Tiles load, no errors, and still a blank page: every cause below is silent
by design somewhere in the stack, so the SDK diagnoses them for you. Each
issue logs one console.error per page, tagged with the codes below:
check the browser console first. (Raw MapLibre users can opt in with the
SDK's exported runMapDiagnostics(…).)
Zero-height container
The number-one cause. MapLibre renders into a canvas the size of its
container; a container that measures 0px tall gets an invisible canvas
(often stuck at MapLibre's small default size, e.g. ~400×300) and no
error. Tiles and glyphs load fine, which makes it look like a data
problem. The SDK logs [container-zero-height] at construction.
Two common ways to get there:
-
A bare
<div id="map">with no height. Fix:<div id="map" style="height: 100vh">(or any real height from your layout). -
Tailwind v4 / CSS cascade layers. Tailwind v4 emits its utilities inside a cascade layer;
maplibre-gl.cssis unlayered, and unlayered CSS beats layered CSS regardless of specificity. Soabsolute inset-0on the map container silently loses to.maplibregl-map { position: relative }the moment MapLibre adds its class, and the container collapses to 0px. Fix: set the container's position/size with inline styles (style={{ position: "absolute", inset: 0 }}), or import maplibre's CSS into a layer that your utilities can override:css/* app.css: declare the layer order, then place maplibre inside it */ @layer vendor, theme, base, components, utilities; @import "maplibre-gl/dist/maplibre-gl.css" layer(vendor);
If the height legitimately arrives later (CSS load, layout pass), the warning can be ignored. MapLibre picks up the size on its next resize.
Container not in the DOM
A container element created but never appended renders nothing.
[container-detached]: append it before constructing the map.
Two maplibre-gl copies
A duplicate dependency, a CDN <script> next to the bundled copy, or
micro-frontends: maps, styles and the pmtiles protocol registration are
not shared between two maplibre-gl instances, with confusing partial
failures. [duplicate-maplibre]: de-duplicate so the app owns exactly one
copy; maplibre-gl is a peerDependency of @mapmap/maps for this reason.
WebGL unavailable
Headless browsers without --use-gl, blocked hardware acceleration,
remote desktops. [webgl-unavailable]: the map cannot render at all.
401 unauthorised
A missing, mistyped or revoked API key: the style loads (public), the
metered tile/routing requests 401, and the map stays empty.
[invalid-api-key]: check the apiKey (keys look like snk_…), or issue
one with POST https://api.mapmap.ai/v1/keys. Related gotcha: the pure
URL builders (buildRouteUrl, buildGeocodeUrl) never embed the key.
Manual fetches need the Authorization: Bearer header.
Basemap tiles 403
The style loads, every basemap tile is refused, nothing paints.
[basemap-forbidden] has two causes:
- No API key, on your own domain. The keyless tiles at
tiles.mapmap.aiserve mapmap.ai and a local development server only. PassapiKeyto the map: from@mapmap/maps0.17.1 a keyed map reads the keyed gateway tiles (https://api.mapmap.ai/tiles/planet/tiles.json?api_key=snk_…), which work on any website. - A browser key used from another origin. A key restricted to a list of allowed origins refuses every other site. Add this site's origin to the key, or use a key without that restriction.
The message never includes the request URL, so your key stays out of the console.
Basemap tiles 404
The style loads, the PMTiles archive 404s, nothing paints.
[basemap-not-found]: check territoryTilesUrl (or your custom style's
tiles URL). The hosted archives live at the CDN root
(https://tiles.mapmap.ai/planet.pmtiles), and a
hash-versioned snapshot URL stops
existing after a data rebuild. Use the stable alias.
Web SDK reference: types and helpers
The exported types and helpers of @mapmap/maps that back the classes above.
Everything here is importable from the package root
(import { createMap, applyTerrain } from "@mapmap/maps"). The pure helpers
(coordinates, style assembly, terrain) are browser-free and safe to run in
Node.
Constructing the map
new MapMapMap(options) (or the createMap(options) alias) takes a
MapMapOptions:
container,apiKey(snk_…),baseUrl(defaults tohttps://api.mapmap.ai),style(aMapMapThemename, a StudioThemedocument, a full MapLibreStyleSpecificationor a style URL; defaults to"light"),center([lng, lat]),zoom,territoryTilesUrl, andmapOptions(extra MapLibreMapOptionsmerged last).- The basemap source: an explicit
territoryTilesUrlalways wins; otherwise a map with anapiKeyreads the keyed gateway tiles ({baseUrl}/tiles/planet/tiles.json?api_key=…, from 0.17.1), and a map without one reads the keyless archive, which serves mapmap.ai and local development only. AstyleURL on the gateway (/styles/…,/tiles/…/style.json) gets the key added. terrainistrueor aTerrainOptionsobject (see below).logois aLogoOptionsobject to reposition or relink the MapMap mark. The mark cannot be removed: passingfalseis accepted so existing code compiles, but it is ignored and logs one warning. Carrying the mark is a condition of the SDK licence.attributionPositionis anAttributionPosition("bottom-left" | "bottom-right" | "top-left" | "top-right", default"bottom-left") for the attribution (the OpenStreetMap and OpenMapTiles credits). The attribution is never removable, and the mark and the attribution are never allowed to share a corner.
SetRouteEffectOptions is the argument to map.setRouteEffect(name, options): the RouteFlowOptions (color, width, speed) plus an
optional geometry (a RouteGeometry); omit geometry and the effect arms
itself and attaches to whatever route a RouteLayer next draws.
registerPmtilesProtocol(gl?) installs the pmtiles:// protocol on a
MapLibre instance. It is idempotent per instance, and MapMapMap calls it
for you; call it directly only if you build your own maplibregl.Map (for
example from your own bundled maplibre-gl copy) and still want to read MapMap
PMTiles.
The MapMap logo control
LogoControlis the MapLibreIControlthat renders the MapMap wordmark. It is typed structurally (onAdd/onRemove) so it needs no maplibre-gl import.LogoOptionsis{ position?: LogoPosition, href? }.LogoPositionis"bottom-right" | "bottom-left" | "top-right" | "top-left"(default"bottom-right", the opposite corner from the credit).LOGO_SVGis the inline wordmark SVG string (20 px tall, white halo), used by the control; it needs no network fetch, so it works offline and under strict CSPs.
The search box control
SearchBox is a drop-in MapLibre IControl search bar: debounced
suggestions as the user types, arrow-key navigation, a typed "select"
event with the picked feature, and, optionally, a fly-to and a marker.
Like LogoControl it is typed structurally, so it imports maplibre-gl
neither at module load nor at runtime.
import { MapMapMap, SearchBox } from "@mapmap/maps";
const map = new MapMapMap({ container: "map", apiKey: "snk_…" });
const search = new SearchBox({ apiKey: "snk_…" });
search.on("select", ({ suggestion, hit }) => {
console.log(suggestion.name, hit?.postcode);
});
map.map.addControl(search, "top-left");
SearchBoxOptions:
| Option | Default | Meaning |
|---|---|---|
apiKey | (none) | Gateway key. Omit on a deployment serving the keyless demo lane |
gatewayUrl | https://api.mapmap.ai | Gateway origin |
placeholder | "Search places" | Input placeholder, also its accessible label |
limit | 5 | Suggestions per request, 1–10 |
debounceMs | 250 | Quiet period before a keystroke becomes a request |
bias | "map-centre" | SearchBoxBias: follow the map view, pin a { lat, lon }, or false for none |
flyTo | true | Fly to the picked result, at a zoom chosen from its kind |
marker | true | Draw a marker on the picked result, under the control's own layer id |
What it costs, plainly. The control calls GET /geocode/suggest once
per debounced keystroke and GET /geocode/retrieve once per pick. The
suggests are free up to 5,000 a day across every key on your account, then
one Standard call each; the retrieve is one Standard call every time.
debounceMs is what keeps a busy box inside the allowance: at the 250 ms
default a typed word costs a call or two, not one per character, so 5,000
covers a few thousand searches a day rather than a few hundred. If you only
need a point, the "select" event's suggestion already carries
lat/lon, so you can ignore the retrieve entirely and pay nothing for
search inside the allowance; see
Search as you type for why our
suggestions include coordinates when the market's usually do not.
The control also fires the quota-free POST /geocode/selection relevance
ping when a result is picked, so what your users actually choose feeds back
into ranking. It is fire-and-forget: a refused ping never surfaces to the
user and never delays the "select" event.
SearchBoxSelectEventis{ suggestion, hit?, query }.hitis the fullGeocodeHitfrom the retrieve, and isundefinedwhen the retrieve failed. The suggestion's own coordinates always survive, so a failed retrieve still leaves you with a usable point.SearchBoxSelectListeneris the"select"handler type.on("select", fn)returns an unsubscribe function;off("select", fn)also works.SearchSuggestionis one dropdown row:{ id, name, context, kind, lat, lon }.SearchBoxMapis the structural slice of a MapLibre map the control needs: amaplibregl.Map, aMapMapMap's.map, or your own fake.
For a search UI of your own, the wire helpers are exported too:
buildSuggestUrl(gatewayUrl, query, options)assembles the suggest URL;SuggestUrlOptionsis{ limit?, bias?, zoom? }withbiasas[lon, lat]. The API key is never embedded; send it as a bearer header.buildRetrieveUrl(gatewayUrl, id)does the same for one document id.parseSuggestResponse(body)turns the response intoSearchSuggestion[], dropping any row the retrieve endpoint could not resolve.DEFAULT_DEBOUNCE_MSis the 250 ms default, exported so your own input can match the control's rhythm.
Accessibility: the input is an aria-combobox with aria-expanded,
aria-controls and aria-activedescendant; the dropdown is a listbox of
options; arrow keys wrap, Enter picks, Escape closes; and focus is
visible.
3D terrain helpers
TerrainOptions configures a DEM: exaggeration (1 is true to life, clamped
0 to 8), url (DEM tile template), encoding ("terrarium" or "mapbox"),
maxzoom, tileSize, attribution and hillshade (add a relief-shading
layer). MapMapMap owns the terrain lifecycle, but the primitives are
exported for maps you build yourself:
resolveTerrain(true | TerrainOptions)normalises the input into a full, clamped config (ornullwhen off).applyTerrain(map, cfg)adds theraster-demsource and switches terrain on; safe to call on everystyle.load, which is required because terrain does not survivesetStyle.removeTerrain(map)turns terrain off and removes whatapplyTerrainadded.DEFAULT_TERRAIN_URLis the Mapzen terrarium tile set on AWS Open Data, the same dataset the gateway reports fromPOST /elevation, so a map and an elevation query agree.DEFAULT_TERRAIN_ATTRIBUTIONis its required credit (the DEM is not covered by the OSM credit the vector tiles carry).
Coordinates
LngLatLike is a longitude/latitude accepted in three shapes: [lng, lat]
(GeoJSON/MapLibre order), { lng, lat }, or { lon, lat }.
toLngLat(point)normalises any of those to a[lng, lat]tuple, throwing on a non-finite or out-of-range coordinate (a guard against swapped axes).formatCoord(point)formats one point as an OSRMlon,latstring.formatCoords(points)formats an ordered list as an OSRM path (lon,lat;lon,lat;…); it requires at least two points, matching the gateway.
Routing types
RouteOptionsis a single route request:profile(aRouteProfile, default"driving"),truck(TruckParams),voice,bannerandlanguage(BCP 47, e.g."en-GB").TruckParamsis the truck/ADR vehicle profile forwarded to the gateway's OSRM truck extensions:heightM,widthM,lengthM,weightT,hazmatandtunnelCode(ADR 8.6.4, e.g."C"or"B/D"; the slash is URL-encoded for you).RouteGeometryis a GeoJSONLineString({ type, coordinates: [lng, lat][] }), ready to hand to a MapLibre source.AdrDimensionsis the physical truck profile for the ADR check (heightM,widthM,lengthM,grossWeightT,axleLoadT,axleCount); unset fields default to the EU 96/53/EC maximum artic (4.0 m / 2.55 m / 16.5 m / 40 t).AdrCheckRequestis the input toPOST /adr/check:hazmat,tunnelCode?(the vehicle load's code),dimensions?andtunnelCategory("A"to"E").AdrCheckResultis the parsed response:{ status, reason?, raw }.
Drawing routes
RouteLayer draws routes; these types shape its look:
RouteLayerOptions(its constructor argument):baseUrl,apiKey,id(source/layer id prefix, default"mapmap-route"),design(a StudioNavRouteDesign),alertsDesign,progressColor(the travelled part of a vanishing route line),alternativeCasingColor,alternativeColor,ferryColor, andendpoints(trueor anEndpointsDesign).RouteLayerIdsis the object returned bylayer.ids, naming every generated MapLibre source and layer (source,casing,line,maneuverSource,maneuver,corridorSource,corridor,endpointsSource,endpoints) so you can reach them withsetPaintPropertyorqueryRenderedFeatures.RouteLineStyle({ color?, dash?, casingOpacity? }) is a runtime override applied over the design or built-in look.EndpointsDesignchooses how start, end and waypoints are drawn:start,end,waypoint(each anEndpointMarkerorfalse) andnumberWaypoints.EndpointMarkeris one designed pin ({ icon?, text?, colour?, size?, label? }); these are symbol-layer markers, so they appear in canvas exports.SIGNAL_BLUE(#3a86ff) is the MapMap brand signal blue, the default route-line colour.
Reachability rings
IsochroneLayer renders reachability contours from POST /isochrone:
ShowReachabilityOptionsis the argument toshowReachability:origin(LngLatLike),mode(aReachabilityMode, default"walk"),minutes(contours, e.g.[5, 10, 15]),colorandcostingOptions(forwarded Valhalla costing options).ReachabilityModeis"walk" | "cycle" | "drive" | "truck"or any raw Valhalla costing string; the friendly names map ontopedestrian,bicycleandauto.IsochroneLayerOptions(its constructor argument) is{ baseUrl?, apiKey?, id? }.IsochroneFeatureCollectionis the GeoJSONFeatureCollectionthe gateway returns andshowReachabilityresolves to (each feature carries acontourin minutes).
Custom layers and the globe
Live Earth layers (events, weather, wind, aurora) are a separate subpath: see Live layers.
From @mapmap/maps 0.18.0, the SDK's own custom layers (the flow route
ribbon, and @mapmap/points point clouds) draw in the right place on a
globe map as well as on mercator, and the helper they use is exported for
your own custom layers. A custom layer that multiplies its geometry by the
render argument's defaultProjectionData.mainMatrix alone is mercator-only:
on the globe that matrix expects positions on the unit sphere.
- 2D geometry on the ground (lines, ribbons, particles): build the
vertex shader with
buildVertexSource(readProjection(args), body), a GLSL ES 3.00 body that callsprojectTile(vec2)on a web-mercator 0..1 position. Keep one program perargs.shaderData.variantNamewithProgramCache, set the prelude's uniforms each frame withsetProjectionUniforms(gl, locateProjectionUniforms(gl, program), args), and run long lines throughsubdivideMercatorso they follow the curve of the planet. - 3D scenes in local metres (X east, Y up, Z south):
modelClipMatrices(args, [lng, lat], altitudeM)returnsmercator(the clip matrix you would have built on mercator),globeandtransition;GLOBE_BLEND_GLSLis the vertex-shader tail that blends them (gl_Position = mapmapProject(vec4(localPos, 1.0))). readProjection(args)isundefinedon a MapLibre that passes no projection data (4.x, early 5.x); keep a mercator path for that case.- On mercator nothing changes: tier 1 compiles to the same expression and the tier 2 mercator matrix is bit-identical to the usual three.js construction.
- Draping custom layers over 3D terrain needs MapLibre 6.13;
supportsTerrainDraping()returnsfalseuntil the SDK supports it. - The building blocks, for layers that need them:
ProjectionArgs(the part of the render argument the helper reads),projectionVariant(args)(aProjectionVariant,"mercator"or"globe"; anything else reads as mercator with one warning),PROJECTION_UNIFORMS(the fiveProjectionUniformnames the prelude declares),DEFAULT_MAX_MERCATOR_SEGMENT(1/512),GLOBE_RADIUS_M(6371008.8, the prelude's radius),sphereSurface(lngLat)andsphereFrame(lngLat)(aVec3on the unit globe and its east, south and up axes),mercatorModelMatrixandglobeModelMatrix(local metres to mercator or to the unit globe),multiplyMat4(a column-major product in three.js's summation order) andClipMatrices(whatmodelClipMatricesreturns).
Models and clipping
@mapmap/maps/models (a subpath; three is an optional peer) adds
ModelLayer for glTF models, and @mapmap/maps/tiles (three and
3d-tiles-renderer) adds TilesLayer for OGC 3D Tiles, both tier-2 custom layers built on modelClipMatrices and
GLOBE_BLEND_GLSL above, so they are placed correctly on mercator, on the
globe and through the blend between them.
- A model is a
ModelSpec:id,url(.glb, meshopt or plain),lngLat,altitudeM,bearingDeg(clockwise from north),scaleandclip. glTF bakes are Y-up metres with the origin at the centre of the base. clip: truehides the basemap's buildings under the model: the convex hull of the model's base, projected to lng/lat, passed tomap.addClipwith the id<layer id>:<model id>.map.addClip(id, { polygon, types, layers, minzoom })on its own is the Mapboxcliplayer: it ANDs a style-specdistancetest ([">", ["distance", polygon], 0]) into the targeted layers' filters, so a feature touching the polygon is not drawn. Default targets are the skeleton'sbuilding,building-3dandbuilding-outline;"symbols"addspoi-labelsandhousenumber. At most 500 vertices; from zoom 13 by default.removeCliprestores each layer's own filter.- Terrain: a model's altitude is above the terrain under its anchor by
default; draping waits for MapLibre 6.13 (
supportsTerrainDraping).
Custom markers
MarkersLayer draws a theme's designed markers and labels (the
extra.markers block, schema v1) on a MapLibre map, alongside the parser,
renderer and constants that back it. Everything here is a locked contract
shared with MapMap Studio's markers panel and the gateway's Rust validator:
ids, defaults and cache-key formats must match across all three.
Reading markers off a theme:
import { MarkersLayer, markersFromThemeUrl, hasBakedMarkers } from "@mapmap/maps";
const markers = await markersFromThemeUrl(
"https://api.mapmap.ai/styles/midnight-fleet-a1b2c3/theme",
);
if (markers) new MarkersLayer(map.map, markers);
markersFromTheme(theme)reads and leniently parses theextra.markersblock from a theme document already in hand;markersFromThemeUrl(url, fetchImpl?)fetches a hosted theme and does the same. Both returnundefinedwhen the theme carries no markers block, so "no markers" is distinguishable from "an empty designed set".parseMarkers(value)is the underlying lenient parser: it returns aMarkersBlock({ version: 1, items: MarkerItem[] }), skipping invalid items (bad or duplicate id, out-of-range coordinates) and falling bad fields back to their defaults, so a hand-edited theme can never break the map. At mostMAX_MARKER_ITEMS(200) survive.MarkerItemis one parsed marker:id,lng,lat,icon(aMarkerGlyphId), optionalimage(a customdata:URI),colour,size(aMarkerSize) and optionallabel.MarkersLayerOptions(the layer's third argument) carriesonImageError, called when a custom image passes the schema but MapLibre cannot rasterise it (usually an SVG with no intrinsic size).MarkersLayerIdsis the object returned bylayer.ids, naming the sharedsourceandlayer(both the contract idmm-user-markers) for escape-hatch styling.
The glyph set is a fixed, ordered contract:
MARKER_GLYPH_IDSis the 21 glyph ids in order (pin,dot,star,heart,flag,home,work,food,cafe,bar,shop,hotel,parking,fuel,charging,transit,bike,camera,music,warning,info);MarkerGlyphIdis that union type;isMarkerGlyphId(v)is the type guard.markerGlyphPathsis the 24×24-viewBox SVG path data for every glyph, keyed by id;MARKER_PIN_BODY_PATHis the coloured teardrop pin body every glyph exceptdotis drawn on;MARKER_GLYPH_VIEWBOX(24) is their coordinate space.
Rendering and cache keys:
renderMarkerImage(options)rasterises one marker toImageDataready formap.addImage, taking aRenderMarkerImageOptions(icon,colour,size, andtextfor short 1–3 character pins, numbered waypoints, lettered stops). It returns aRenderedMarkerImage({ image, pixelRatio: 2 }), ornullin non-DOM environments.markerImageKey(icon, colour, size),markerTextImageKey(text, colour, size)andmarkerImageDataKey(dataUri)build the stablemap.addImagecache keys (glyph pins, short-text pins, and hashed custom images respectively) so identical markers share one registered image.markerImage(v)validates a customdata:image URI (png/jpeg/webp/svg, at most 64 KB decoded) or returnsundefined;markerText(v)normalises a short pin text;truncateChars(text, max)cuts a string by Unicode scalar (the same count the Rust validator uses).hasBakedMarkers(map)reports whether the style already carries compiler-baked marker layers, in which case the markers render in any MapLibre client with no SDK code, and you only need aMarkersLayerto change them at runtime or to draw custom-image markers a static sprite cannot carry.BAKED_MARKERS_GLYPH_ID(mm-user-markers-glyph) is the baked glyph layer that signals it;MARKERS_ID(mm-user-markers) is the runtime source and layer id.
The contract constants and their defaults: MARKER_SIZES (["s", "m", "l"]),
MARKER_SIZES_PX (the CSS pixel size of each preset), MARKER_LABEL_TEXT_SIZES
(label px per preset), MARKER_DEFAULT_COLOUR (#1a6bff, marker blue),
MAX_MARKER_IMAGE_BYTES (64 KB), MAX_MARKER_ITEMS (200),
MAX_MARKER_ID_LENGTH (64), MAX_MARKER_LABEL_LENGTH (120) and
MAX_MARKER_TEXT_LENGTH (3). The MarkerSize type and MarkersBlock
interface complete the set.
POI category design
The POI theming helpers (see POI theming above) are all exported for building your own tooling:
PoiDesignis the parsedextra.poiblock ({ version: 1, categories });PoiCategoryDesignis one category's optionalcolor(dot) andtextColor(label) overrides.parsePoiDesign(value)leniently parses a raw block. Unknown category ids and invalid colours are dropped, never fatal.poiDesignFromTheme(theme)reads it from a theme document (returningundefinedwhen no block is present), the in-hand counterpart topoiDesignFromThemeUrl.defaultPoiDesign()is the empty (all-built-in) design;poiDesignIsDefault(design)is true when a design changes nothing.poiTextColorExpression(design, fallback)builds the MapLibretext-colorvalueapplyPoiDesignsets on thepoi-labelslayers: a plain colour when nothing overrides label text, else amatchon the tile'sclass.POI_CATEGORY_COLORSis the ordered[id, colour]list of the eight categories with their built-in dot colours;POI_CATEGORY_IDSis just the ids in order;POI_CLASS_CATEGORIESmaps each OpenMapTilespoiclass into its category (anything unlisted falls through toservices).builtInPoiColor(categoryId)looks up one category's default dot colour.
Your own places (store finder)
PlacesLayer drops your own points (stores, depots, POIs) onto a map with
clustering, popups and nearest-branch helpers (the store-finder use case).
Its supporting types:
PlacesInputis what the layer andsetPlacesaccept: aPlace[]or aPlacesFeatureCollection(a GeoJSONFeatureCollectionofPlacePointFeaturepoints).placesFromGeoJSON(collection)is the normaliser that turns the collection intoPlace[], deriving each id fromfeature.id, thenproperties.id, then the index.PlacesLayerOptions(the constructor argument) coverscluster,clusterRadius,clusterMaxZoom,color,clusterColor,icon(aPlacesIconor a record of them for per-place pins),iconProperty,label,fitBounds,onPlaceClickandpopup.PlacesIconis one custom pin image ({ url, size? });PlacesLabelOptionsconfigures the per-place name labels (property, size, and colours in both British and American spellings).PlacesLayerIdsis the object returned bylayer.ids, naming every generated source and layer (source,points,clusters,clusterCounts,labels,pointsFallback) for escape-hatch styling.PlacesSelectOptionsis the argument tolayer.select(id, options)for list-to-map sync:popup,flyToandzoom.nearest(origin, n)returnsPlaceWithDistance[](aPlaceplus straight-linedistanceM);nearestByDriveTime(origin, options)returnsPlaceWithDriveTime[](durationSplus drivendistanceM) via the gatewayPOST /matrix, taking aNearestByDriveTimeOptions(n,costing,costingOptions).haversineDistanceM(a, b)is the pure straight-line distance helper behindnearest.
Manoeuvre and lane icons
The turn-by-turn glyph set (also the @mapmap/maps/direction-icons subpath)
and the lane helpers GuidanceBanner renders:
directionIconsis the record of inline 20×20 SVG markup for all 116 manoeuvre icons, keyed by name;DirectionIconNameis that key union. The manoeuvre iscurrentColor; secondary shapes (class="mm2": the untravelled ring, other lanes, ferry water) are the same colour at 35% opacity, never a fixed grey. Retune with.mm2 { fill-opacity: var(--mm-icon-secondary-opacity, 0.35); stroke-opacity: var(--mm-icon-secondary-opacity, 0.35) }; the old--mm-icon-secondarycolour property is retired.- Roundabouts come in two drawings: right-hand traffic circulates
anticlockwise (
roundabout_*,rotary_*), left-hand traffic such as the UK clockwise (roundabout_lht_*,rotary_lht_*). The driving side picks one and is never assumed: with no side a roundabout draws the plain turn glyph for its exit direction. iconNameForManeuver(type, modifier, drivingSide?)maps an OSRM-shaped manoeuvre to aDirectionIconName, degrading unknown input gracefully to a renderable turn icon;directionIconSvg(type, modifier, drivingSide?)returns the markup directly. There is no defaultdrivingSide: pass"left"or"right"to get a roundabout ring or a directed u-turn.iconNameForStep(step)is the preferred single-step entry point: it reads aDirectionIconStep(maneuver,driving_side,mode) and takes the roundabout drawing, u-turn arm and ferry mode from the step itself (the MapMap gateway sendsdriving_sideon roundabout and u-turn steps).iconNamesForSteps(steps)resolves a whole turn list, because a roundabout's through-angle icon can only be worked out by pairing its enter and exit steps (22.5 / 67.5 / 112.5 / 157.5 degree buckets, beyond that a roundabout u-turn); the pair shares one driving side.extractGuidancesetsStepGuidance.bannerIcon, the glyph for the manoeuvre a step's banners describe (the next step's), andGuidanceBanner.update(banner, step)draws it.LaneIndicationis one lane's guidance (directions,valid,active,activeDirection);laneArrowDirection(lane)picks the direction to draw (the resolvedactiveDirection, else the first permitted direction);directionArrow(direction)is the Unicode-arrow text fallback for a direction (sprite lane icons ship with the map assets).
Speed-limit signs and road-number shields
The @mapmap/maps/road-signs subpath (not re-exported from the main entry,
so apps that never draw a sign never load it) draws each country's own
speed-limit sign and route shields from the official dimensions, with
MapMap's own numerals as paths: no font file, and a sign never follows the
user's font size.
import { resolveSpeedSign, speedSignSvg, resolveRoadRef, roadRefSvg } from "@mapmap/maps/road-signs";
const sign = resolveSpeedSign({ speedLimitKph: 48, country: "GB" }); // numeric 30 mph, the GB roundel
el.innerHTML = speedSignSvg(sign, 48); // "" when nothing may be shown
const [shield] = resolveRoadRef({ ref: "I 95", country: "US" });
badge.innerHTML = roadRefSvg(shield, 24); // Interstate 95
resolveSpeedSign({ maxspeed?, maxspeedType?, speedLimitKph?, country?, subdivision? })resolves through the road's country: mph in GB and its dependencies and the US and its territories, km/h elsewhere. An unknown limit, an unknown country andwalkrender nothing, never a number with a guessed unit.nonedraws the German end-of-restrictions sign in Germany only. The route API's steps carrycountryandsubdivisionfor exactly this.speedSignSvg(sign, size, { surface, fitMode }): 24, 32, 48 or 64 px (or any size). Below 32 the unit caption and legend words drop.surface: "dark"adds a white halo drawn into the SVG;fitMode: "figma60"narrows the numerals to 60 percent of the inner disc (the default"official"follows the official drawings).resolveRoadRef(...)gives one shield per concurrent route, at most three, never an invented one; refs longer than six characters come back as text.roadRefSvg(ref, height)stretches the shield to its text.speedSignLabelandroadRefLabelreturn the accessible labels the SVGs carry ("Speed limit 30 miles per hour", "Motorway M25"). Passlocaleto the resolvers for German, French, Italian, Spanish, Dutch or Polish labels in each country's own sign terms ("Vitesse limitée à 50 kilomètres par heure", "Autobahn A 9"); other locales read en-GB.- Where GB, IE, AU or NZ signs are shown, show the matching line from
signAttributions(Crown copyright under the Open Government Licence; CC BY 4.0 for IE, AU and NZ).
Road-number shields on the map
Every MapMap style (hosted styles, buildStyle in the web SDK, the website
maps) draws road numbers on each country's own shield, in a symbol layer
called road-shields just below the POI labels: M25 on the GB motorway
blue, A27 on the primary-route green, A 7 on the French red, B 12 on the
German yellow, 280 on the Interstate shield, and so on, plus E-roads. The
country comes from each road's own tile data, so a world map shows every
country's shields at once; a number whose country the tile does not say is
drawn on a plain white pill, never on another country's shield. The layer
follows resolveRoadRef, so the map, route steps and the Nav app agree, and
it uses stretchable sprite images, so it renders the same on MapLibre GL JS
and MapLibre Native.
- Shields appear from z6 on motorways, z8 on national routes and z11 on regional roads, with text at 12, 14 and 16 px as you zoom in.
- From z13 street names come first: a second layer,
road-shields-street, sits just below the road names and draws only motorway and national shields, where a name does not already take the space. Regional and local shields stop at z13 unless you move their minimum zooms lower. buildStyle({ roadShields: false })leaves the layer out; in a theme,territories.<id>.road_shieldshides it or moves the minimum zooms (see the theme fields above).- A style built without a
spritepoints athttps://api.mapmap.ai/sprite/sprite, which carries the blanks. If you set your ownsprite, the layer is left out unless that sheet is a MapMap/sprite/sprite(for example your self-hosted gateway's). - Where a road carries several route numbers, the first one is drawn.
Style assembly and constants
buildStyle(options) compiles a BuildStyleOptions into a full MapLibre
style over the MapMap territory tiles, mirroring the sn-style crate so
client-side and build-side styles compile identically.
BuildStyleOptionsis{ labelLanguage?, theme?, territoryTilesUrl?, apiKey?, baseUrl? }, wherethemeis aMapMapThemename or a fullThemedocument.apiKey(with noterritoryTilesUrl) selects the keyed gateway tiles;territoryTilesSourceUrl(options)returns the source URL on its own.MapMapThemeis the built-in palette name,"light" | "dark".MAPMAP_BASESis the named base catalogue, each entry{ id, label, description, group, notCompiledHere, theme() }.baseTheme(id)returns a fresh, editable document for one (orundefinedfor an id that does not exist),namedBase(id)returns the whole entry,resolveBaseId(id)follows the retiredcalm-cab-*ids, andstyleFromBase(id, options?)builds the MapLibre style in one call, throwing and naming the accepted ids if the name is wrong.MapMapBaseIdis the union of the ten ids. Honestly:notCompiledHerelists the theme fields this reduced compiler cannot honour for that base (Streets' per-label fonts, Outdoor's terrain, Print's text scale); a hosted style URL or the static map API renders the full document.LayerOverrideis one per-layer override merged onto a skeleton layer:{ visible?, paint?, layout?, filter?, minzoom?, maxzoom? }.ThemeEffectsis the optional visual-effects block ({ route?: "flow" | "none", params? }), compiled into the style's metadata so a hosted style URL or a baked package can carry it.toPmtilesUrl(url)prefixes a tiles URL withpmtiles://if it is not already there.PALETTE_SLOTSis the ordered list of[slot, lightDefault, darkDefault]palette entries, andSOURCE_LAYERSis every OpenMapTiles source-layer the tiles emit and the skeleton styles. In@mapmap/maps0.12.0PALETTE_SLOTScarries all 25 palette slots the gateway accepts, in the same order.OSM_ATTRIBUTION(© OpenStreetMap contributors),OPENMAPTILES_ATTRIBUTION(© OpenMapTiles) andFULL_ATTRIBUTION(both combined) are the legally required credits stamped on every compiled style's tile source; no theme field can remove them.DEFAULT_GLYPHS_URLis the default PBF fontstack endpoint, andDEFAULT_TERRITORY_TILES_URLis the keyless territory PMTiles URL (overridable per map).keyedTilesUrl(apiKey, baseUrl?)is the keyed one, the gateway TileJSON forKEYED_TILES_TERRITORY("planet"), andkeyedStyleUrl(url, apiKey, baseUrl?)adds the key to a gateway style URL.DEFAULT_GATEWAY_URLishttps://api.mapmap.ai.effectsFromStyleMetadata(metadata)reads a compiled style's effects block back out ({ route: "flow", params? }orundefined), andEFFECTS_METADATA_KEY("mapmap:effects") is themetadatakey it lives under.MapMapMapuses these to auto-enable a route effect from the style alone.
Attribution
Map data derives from OpenStreetMap and the tiles follow the OpenMapTiles schema and cartography. Anything you render or republish must credit "© OpenStreetMap contributors" with a link to openstreetmap.org/copyright (ODbL), and "© OpenMapTiles" with a link to openmaptiles.org (CC-BY 4.0). Every document the tiles API serves carries both credits, and so do the compiled styles, so a MapLibre map renders them for you.
Next steps
- Conventions: base URL, auth, error envelope, quotas and ADR tunnel codes
- Territories: which territories exist and what they cover
- SDKs: the full
@mapmap/mapsreference - MCP guide: style the map from an agent
The seabed ramp reads a z10 DEM
seabed-relief reads seabed-dem: the same tiles, encoding and tile size as hillshade-dem, with maxzoom: 10 instead of 13. Mapzen terrarium carries real bathymetry to tile zoom 10 and uniform 0 m placeholders above it, so a source reaching past z10 hands the depth ramp two different answers for one patch of open sea depending on which tile the cache kept: a tile painted from a cached z10 parent takes the deep end of the ramp, its neighbour painted from a real, flat higher-zoom tile paints nothing. That rendered as a hard-edged navy/pale patchwork on tile boundaries at z13-z14, cache-dependent, so it came and went as you zoomed. Capping the source removes the choice: above z10 every tile is an overzoom of the same real bathymetry (measured over the Ligurian Sea at z13, open-sea luminance spread p05-p95: 89.8 before, 0.2 after). The hillshade keeps maxzoom: 13, because it shades the land, where every level is real data. The source is emitted only alongside sea_relief, so a style without it is byte-identical.
3D mesh stops at z10
The terrain-dem source (the 3D mesh) is capped at maxzoom: 10, the last level at which the default DEM carries bathymetry, while hillshade-dem keeps maxzoom: 13. MapLibre builds each terrain tile's bounding box from the DEM one level up and its far plane from the tile itself; where a DEM switches from real bathymetry to flat placeholders (Mapzen terrarium does at z11), an ancestor's box can sit wholly below the child's far plane and the whole subtree is culled: nothing is drawn over open sea. Capping the mesh keeps the ranges nested. Land relief comes from the hillshade source and is unaffected; the mesh cap shifts 3D silhouettes by about a pixel at z13–z15.