# 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](/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](/signup) for a key, or run the [self-host distro](/docs/self-host);
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](/docs/territories) for coverage).

Set up your shell once so every snippet on this page is copy-pasteable:

```sh
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. No key yet? The
[quickstart](/docs/quickstart) issues one in a single `curl`; base URL, auth
and the error envelope are defined in full on the
[conventions page](/docs/conventions).

![Searching and interacting with POIs on a live MapMap map](/docs-videos/map-search-poi.mp4)

## 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, OSM attribution |
| GET | `/tiles/{territory}/style.json` | A ready-to-render MapLibre GL style over the territory's vector source |

All three take your normal API key and are metered per request at the
Standard price class (see [pricing](/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](/docs/territories); on a self-hosted deployment
`GET /territories` reports what you have staged.

```sh
curl -fsS "$BASE/tiles/uk/tiles.json" -H "Authorization: Bearer $API_KEY"
```

```json
{
  "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"
}
```

(The `?v=` discriminator busts caches atomically when the operator republishes
a territory archive; keep it in the URLs you pass through.)

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

```html
<!-- In production, pin an exact maplibre-gl version and add SRI
     (integrity="sha384-…" crossorigin="anonymous"), or bundle it via npm. -->
<link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>

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

<script>
  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>
```

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` | none | List 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 |

Reads are public and unmetered, so compiled styles can be referenced by bare
URL from browser map clients. The hosted gateway ships two house styles,
MapMap Light and MapMap Dark, listed by `GET /styles`; every style you
publish appears alongside them under your own id.

### 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](#the-theme-document) |

```sh
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`:

```json
{
  "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:

```sh
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`:

```json
{
  "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:

```json
{
  "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, building, aeroway, road, roadMajor, 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](/docs/conventions).

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

```json
{
  "name": "midnight-fleet",
  "base": "dark",
  "palette": { "water": "#0b2038", "roadMajor": "#8a6d3b" },
  "layers": {
    "building": { "visible": false },
    "road-minor": { "paint": { "line-width": 2 }, "minzoom": 10 }
  }
}
```

- `base` is `light` or `dark`; the 19 palette slots below recolour the whole
  map. Colours are `#rgb`/`#rrggbb`/`#rrggbbaa` or `rgb()`/`rgba()`/`hsl()`/`hsla()`.
- Per-layer overrides: `visible`, `paint`/`layout` (per-key merge), `filter`,
  `minzoom`/`maxzoom`.
- `fonts.regular` sets the label fontstack (default `"MapMap Sans Regular"`);
  keep it on a [bundled fontstack](#fonts-and-sprites) or labels drop.
- `fonts.labels` optionally retypesets label types individually:
  `{ "country", "place", "road", "water", "poi", "housenumber" }`, each a
  fontstack string; absent kinds inherit `fonts.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 a `422` naming the
  hosted stacks.
- `glyphs` and `sprite` optionally override the compiled style's glyph and
  sprite URLs.
- `extra_layers`: extra fully-formed MapLibre layers inserted above the base
  map, below labels. Each must use the `territory` source and 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`.
- `extra`: an opaque block the compiler ignores (capped at 256 KB serialised);
  Studio stores its [navigation design](#navigation-design) and
  [POI theming](#poi-theming) here.
- `buildings_3d`: `true` renders buildings as 3D extrusions from OSM height
  data; see [3D buildings](#3d-buildings) for where to (and not to) use it.
- 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 19 slots, with the `light`/`dark` base defaults. `roadMotorway`
inherits whatever `roadMajor` resolves to until you set it explicitly, so
recolouring `roadMajor` alone moves motorways too; set both for the
classic orange-motorways-over-yellow-primaries look:

| Slot | Light | Dark |
|---|---|---|
| `background` | `#f4f2ee` | `#12161c` |
| `water` | `#a8c8e8` | `#1b2c40` |
| `waterway` | `#a8c8e8` | `#1b2c40` |
| `landcover` | `#e3e8dd` | `#182029` |
| `landuse` | `#ece8e1` | `#171e26` |
| `park` | `#c8e2bf` | `#1a2a1f` |
| `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 29 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`, `poi-labels`,
`mountain-peak-labels`, `aerodrome-labels`, `place-labels`,
`country-labels`.

`landuse` and `landcover` carry a `match` on 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 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.

### 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:

```ts
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](https://github.com/maplibre/maplibre-native/issues/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.

![3D buildings and the globe projection on a live MapMap map](/docs-videos/map-3d-globe.mp4)

## Studio

[Studio](/studio) is the browser editor over the same theme engine, in four
tabs: **Map design** (pick the base, recolour the palette slots, toggle layers
and adjust widths and opacities per layer, colour the
[POI categories](#poi-theming), with a live map preview),
**Navigation** (below), **Markers** (place coloured glyph pins, dots or small
custom images with labels, saved with the style so the SDKs draw them), and
**Publish**. Your working theme autosaves in the browser.

The Map design tab's preset picker offers first-party starting points: the
plain **Light** and **Dark** bases, plus **Calm Cab Day** and **Calm Cab
Night**: low-distraction in-cab themes with muted landcover, dimmed water, a
prominent road hierarchy and POI label noise switched off, tuned so the
default route line stays the highest-contrast element on screen. A preset
loads into the editor as an ordinary theme document (your navigation design is
kept), so everything remains editable and publishable as your own style.

From the Publish tab'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](/docs/mcp).

![Designing a theme live in Studio, then publishing it through the theme compiler](/docs-videos/studio-theming.mp4)

### 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:

```json
{
  "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" }
        }
      }
    }
  }
}
```

- `route`: line colour, width (px at mid zoom), opacity and casing colour;
  the defaults are the web SDK `RouteLayer` look.
- `puck`: current-position colour, diameter in px, and whether the heading
  arrow is drawn. `imageUrl` (optional since v1) sets a custom puck image:
  an `https:` or `data:` URI (PNG/SVG) that replaces the built-in dot and
  arrow; `size` still 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 SDK `GuidanceBanner`
  conventions. `padding` (px, 6–24, default 10), `cornerRadius` (px, 0–24,
  default 10), `maxWidth` (px, 200–520, default 340) and `height` (px,
  40–120, unset = auto content height) are optional since v1; the block
  stays `"version": 1` and 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 are `background`,
  `textColor`, `outlineColor`, `cornerRadius` (px, 0–24) and
  `outlineWidth` (px, 0–4); `escalatedBackground` and `escalatedTextColor`
  style the over-the-limit state. `iconSet` is one of `european`, `us`,
  `minimal` or `brand`, with optional per-kind `iconUrlOverrides`
  (`https:` or `data:` URIs only). `alertSound` and `escalatedSound` pick
  the two earcons from the built-in library (`cameraCalm`, `cameraUrgent`,
  `overspeedSoft`, `zoneEnter`, `zoneClear`), each with an optional
  `alertSoundUrl` / `escalatedSoundUrl` override under the same `https:`
  or `data:` rule. `mapIconSize` (0.75–1.5 of the default
  pin ramp) and `mapMinZoom` (9–14) control the map pins, `corridorColor`
  and `corridorOpacity` (0–1) tint the average-speed corridor on the route
  line, `leadDistance` is one of `short`, `standard` or `long` (150 m,
  250 m and 400 m floors), and `chipPosition` is one of `aboveSpeed`,
  `topLeading` or `topTrailing`. `kinds` carries one entry per camera kind
  (`fixed`, `average`, `red_light`, `mobile_site`, `unknown`), each with
  `showOnMap`, `showOnMapWhileNavigating`, `alertWhileDriving` and
  `showInRoutePreview` booleans, an `alertMode` of `always`,
  `whenSpeeding` or `never`, and an `audioMode` of `off`, `earcon` or
  `earcon_and_speech`. Themes saved without the block load unchanged and
  stay `"version": 1`; a block that is present always parses complete,
  escalated tokens included.

  `showOnMap` and `showOnMapWhileNavigating` are 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 only
  `showOnMap`, and its navigating value reads as `true`.

  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
  `audioMode` to `off` if 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 to `navAlertSoundUrl`.

  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
  `alertMode` says 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's
  `NavigationCamera` reads as its defaults. `pitch` (degrees, 0–85, default
  60), `zoom` (14–20, default 17.5) and `speedMps` (drive speed in metres
  per second, 2–40, default 12). Explicit `NavigationCamera` options 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:

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

…or fetch the design straight from a hosted style's theme endpoint
(public, uncached, always the latest publish):

```ts
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](/docs/sdks) for
the full reference.

### POI theming

The **POIs** panel (Map design tab) 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:

```json
{
  "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:

```ts
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`:

```ts
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](/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. Arabic and Hebrew shaping needs
MapLibre's RTL text plugin: call `maplibregl.setRTLTextPlugin(…)` once in
your app, as the live map does.

## 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` (plus Bold and Italic, all three
  composited with the merged Noto stacks so every script renders),
  `Noto Sans Regular` (plus Bold and Italic), `Barlow Regular`,
  `Fira Sans Regular`, `IBM Plex Sans Regular`, `Inter Regular`,
  `Lato Regular`, `Montserrat Regular`, `Noto Serif Regular`,
  `Nunito Regular`, `Open Sans Regular`, `Rubik Regular`,
  `Source Sans 3 Regular` and `Work Sans Regular`, all SIL Open Font
  Licence, static instances. Keep every fontstack the theme names on one
  of these or those labels drop. 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, road shields, tunnel-restricted).

**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](/docs/self-host)).

## Web SDK quickstart

**Availability:** [`@mapmap/maps` is live on
npm](https://www.npmjs.com/package/@mapmap/maps).

`@mapmap/maps` wraps MapLibre GL with the MapMap style, the `pmtiles`
protocol and typed routing helpers:

```sh
npm install @mapmap/maps maplibre-gl pmtiles
```

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.

```ts
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](/docs/conventions). The `style` option accepts a Studio
theme document directly, so a style built in Studio drops straight into your
app. Full SDK reference: [SDKs](/docs/sdks).

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. The full recipe is in the
[`@mapmap/maps` README](https://www.npmjs.com/package/@mapmap/maps)
("Map screenshots / static image export").

## Landmark directions

Turn instructions on [the map](/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, pubs and fast food score highest, and a
known brand raises the score), 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. The same phrasing engine
will feed spoken guidance: voice support is coming to the SDKs.

## 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](/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](/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.

## Attribution

Map data derives from OpenStreetMap. Anything you render or republish must
credit "© OpenStreetMap contributors" with a link to
[openstreetmap.org/copyright](https://www.openstreetmap.org/copyright); the
compiled styles carry this for you.

## Next steps

- [Conventions](/docs/conventions): base URL, auth, error envelope, quotas and ADR tunnel codes
- [Territories](/docs/territories): which territories exist and what they cover
- [SDKs](/docs/sdks): the full `@mapmap/maps` reference
- [MCP guide](/docs/mcp): style the map from an agent
