npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mapmap/maps

v0.12.0

Published

MapMap Maps SDK: a thin, well-typed MapLibre GL JS wrapper that drops a MapMap-branded map with signed territory tiles and OSRM-compatible truck/ADR routing into any web app.

Readme

@mapmap/maps - MapMap Maps SDK

A thin, well-typed TypeScript wrapper over MapLibre GL JS that drops a MapMap-branded map (signed territory PMTiles) plus OSRM-compatible truck / ADR routing into any web app - the way developers use Mapbox GL, but on MapMap's own tiles and routing gateway.

The SDK does not reimplement MapLibre. MapLibre does all the rendering; this package wires in the MapMap default style, registers the pmtiles protocol, and gives you typed helpers for routing and ADR tunnel compliance against the MapMap gateway.

Install

npm install @mapmap/maps maplibre-gl pmtiles

maplibre-gl and pmtiles are peer dependencies - your app owns a single shared copy (two MapLibre instances on one page break the WebGL context).

The MapLibre peer range is >=5.0.0 <7.0.0: MapLibre GL JS 5 and 6 are both supported, 4.x is not. MapLibre 6 requires WebGL 2 (it dropped the WebGL 1 fallback), so the map needs a WebGL 2 context; a browser or environment without one gets the [webgl-unavailable] diagnostic and no map.

Bundlers that cannot rewrite MapLibre 6's worker URL (Turbopack among them) additionally need maplibregl.setWorkerUrl(…), called before the first map is constructed, with maplibre-gl-worker.mjs and maplibre-gl-shared.mjs served by your app. Without it the map shows no tiles and logs nothing at all. The Next.js recipe and the 5.x feature-detection note are in the 0.12.0 changelog entry.

You also need a MapMap gateway API key (snk_…). See docs/SDK-DISTRIBUTION.md for licensing.

Building with an AI agent? The package ships llms-sdk.txt - a concise, agent-facing integration guide (init, key issuance, routing, places, flythrough, effects, isochrones, and the classic gotchas). Point your coding agent at it.

Quickstart

import { MapMapMap, RouteLayer } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";

const map = new MapMapMap({
  container: "map",
  apiKey: "snk_…",
  style: "light", // "light" | "dark" | a StyleSpecification | a style URL
  center: [-1.5, 52.6],
  zoom: 6,
});

await map.whenReady();

const routes = new RouteLayer(map);
const route = await routes.route(
  { lng: -0.1278, lat: 51.5074 }, // London
  { lng: -1.5106, lat: 52.4081 }, // Birmingham
  {
    profile: "truck",
    truck: { heightM: 4.0, weightT: 40, hazmat: true, tunnelCode: "C" },
  },
);

console.log(route.distanceM, route.durationS); // metres, seconds

The route line is drawn automatically in MapMap signal blue (#3a86ff) with a darker casing. Call routes.clear() to remove it, or routes.route(...) again to replace it.

API surface

class MapMapMap (alias: createMap(options))

Wraps maplibregl.Map.

new MapMapMap({
  container,            // string id or HTMLElement (required)
  apiKey?,             // gateway key, reused by RouteLayer/AdrCheck
  baseUrl?,            // gateway origin, default https://api.mapmap.ai
  style?,              // "light" | "dark" | Theme | StyleSpecification | URL
  territoryTilesUrl?,  // override the PMTiles URL in the default styles
  center?, zoom?,      // initial view ([lng, lat], number)
  mapOptions?,         // extra native MapLibre MapOptions (escape hatch)
});
  • .map - the underlying maplibregl.Map; use it for any native call.
  • .whenReady() - resolves after the style and first tiles load.
  • .destroy() - removes the map and frees the WebGL context.
  • .apiKey, .baseUrl - read back for your own gateway calls.
  • .navDesign - the parsed Studio extra.nav block when the style theme carried one (see "Using your Studio design").

class RouteLayer

const routes = new RouteLayer(map /* MapMapMap or maplibregl.Map */, {
  baseUrl?, apiKey?, id?, design?, // design: extra.nav route block
  endpoints?,                      // start/end/waypoint markers, off by default
});

await routes.route(from, to, { profile, truck });      // two-point
await routes.routePath([a, b, c], { profile, truck }); // multi-point
routes.draw(parsedRoute);          // a route parsed elsewhere
routes.current;   // last ParsedRoute
routes.ids;       // the source/layer ids this layer owns
routes.clear();   // remove the drawn line and everything hung off it
routes.destroy(); // clear() + detach the style.load listener

// Draw geometry you already have, with no ParsedRoute to hand
routes.drawGeometry([[lng, lat], ...]);

// Intermediate stops, for the numbered waypoint markers
routes.setWaypoints([[lng, lat]]);
routes.currentWaypoints;   // read them back

// Alternatives: one selected, the rest dimmer beneath and clickable
routes.drawAlternatives(parsedRoutes, selectedIndex);
routes.onSelectAlternative((i) => routes.drawAlternatives(parsedRoutes, i));
routes.alternativeRoutes;  // what is currently drawn as an alternative

// Ferry legs as dashes over the selected line, so water does not read
// as driving. Supplied explicitly: line-dasharray cannot be data-driven,
// and only you know which parts of your route are ferries.
routes.setFerrySegments([[[lng, lat], ...]]);
routes.clearFerrySegments();

// Runtime paint, over the design. Survives style reloads; `{}` clears.
// A provisional or straight-line-approximated route, for example:
routes.setLineStyle({ color: "#9096a2", dash: [1.6, 1.4], casingOpacity: 0 });

ids names every MapLibre id the layer owns, so you can restyle or reorder them: source, casing, line, maneuverSource, maneuver, corridorSource, corridor, endpointsSource and endpoints. All are derived from the id option (default mapmap-route).

Start, end and waypoint markers

endpoints: true draws branded markers on the route: a green dot at the first coordinate, a signal-blue pin at the last, and smaller pins numbered 1..n at each intermediate stop. Override any of them, or pass false to drop one:

new RouteLayer(map, {
  endpoints: {
    start: { icon: "home", colour: "#3ecf8e", size: "m", label: "Depot" },
    end: { text: "B" },      // 1–3 characters beat the glyph
    waypoint: false,         // no intermediate markers
    numberWaypoints: true,   // default
  },
});

The stops come from routePath's via points; when you hand a route parsed elsewhere to draw() or drawGeometry(), set them with routes.setWaypoints([[lng, lat], …]) and read them back from routes.currentWaypoints. An empty array clears them.

The markers are a symbol layer over their own source (both ids <id>-endpoints, on routes.ids as endpoints and endpointsSource), not DOM markers, so they appear in canvas exports and match the native SDKs. A pin anchors at its tip, a dot at its centre; label draws below the marker with a white halo and is optional to the collision detector. They survive setStyle, and clear()/destroy() removes the layer, the source and the images this layer registered. Omit the option and the layer behaves exactly as before.

Coordinates accept [lng, lat], { lng, lat } or { lon, lat }. A route resolves to { distanceM, durationS, geometry (GeoJSON LineString), raw }.

The design block takes color, width, opacity and casingColor, plus three optional fields: casingWidth (defaults to width + 4), casingOpacity (defaults to opacity) and dash (omitted or null draws solid). Set the first two when your casing is not exactly four wider than your line, or when it is translucent under a solid line.

truck params map onto the gateway's OSRM truck vendor extensions: heightM, widthM, lengthM, weightT, hazmat, and tunnelCode (ADR 8.6.4, e.g. "C" or "B/D" - the slash is URL-encoded for you). They only take effect with profile: "truck".

class PositionPuck

The current-position marker: a coloured dot with a heading arrow, or a custom image, styled by a Studio design (see "Using your Studio design").

const puck = new PositionPuck(map /* MapMapMap or maplibregl.Map */, design?);
puck.setLocation({ lat, lon }, headingDeg?); // adds it on first call
puck.remove();

class PlacesLayer - places / store finder

⚠️ Give the map container an explicit height first. MapLibre silently renders into a 0px-tall canvas when the container's height resolves to zero (a bare <div id="map"> with no CSS) - no error, no map, no pins. Set #map { height: 100vh; } (or any real height) before debugging anything else.

Drop your own places (e.g. 300 store locations) onto the map: clustered pins, popups, and "nearest branch" answers by straight line or by drive time (via the gateway's POST /matrix).

import { PlacesLayer } from "@mapmap/maps";

const stores = new PlacesLayer(map /* MapMapMap or maplibregl.Map */, {
  places: [
    { id: "bhm-01", name: "Birmingham", lat: 52.4862, lon: -1.8904 },
    { id: "man-01", name: "Manchester", lat: 53.4808, lon: -2.2426 },
    // …or a plain GeoJSON FeatureCollection of Points
  ],
  cluster: true,                      // default; clusterRadius?, clusterMaxZoom?
  color: "#ff6b35",                   // pin colour: string or expression (below)
  fitBounds: true,                    // fit the view to the places on first set
  popup: (place) => `<strong>${place.name}</strong>`,
  onPlaceClick: (place, lngLat) => console.log(place.id, lngLat),
});

stores.setPlaces(nextPlaces);                    // replace data - never dropped
stores.select("man-01");                         // list→map sync: popup + camera
stores.deselect();                               // close the popup
stores.nearest({ lat: 51.5, lon: -0.13 }, 3);    // haversine, adds distanceM
await stores.nearestByDriveTime({ lat: 51.5, lon: -0.13 }, { n: 3, costing: "truck" });
stores.ids;       // { source, points, clusters, clusterCounts, labels, pointsFallback }
stores.clear();   // remove pins;  stores.destroy() also detaches listeners

Clicking a cluster zooms in to expand it; the cursor becomes a pointer over pins. Pass icon: { url, size? } for a custom pin image (falls back to the circle pin if it fails to load). nearestByDriveTime sorts by durationS (seconds, driven distanceM attached, unreachable places dropped) and reuses the map's baseUrl/apiKey - or pass them as layer options.

setPlaces never silently drops an update: once the source exists the data is applied immediately - even mid-render, while map.isStyleLoaded() is transiently false - and calls made before the style has first loaded are stashed (the latest one wins) and installed on style.load. Search-as-you-type just works.

Per-category pin colours

color also accepts a MapLibre expression, evaluated against each feature's properties. Everything in a place's properties is copied verbatim onto the top level of its GeoJSON feature's properties, alongside the reserved id, name and __mapmapIndex keys (which win on collision) - so a scalar like category is directly ["get", …]-able. MapLibre JSON-stringifies nested objects/arrays at render time, so keep anything you want to style on as a top-level string/number/boolean:

const stores = new PlacesLayer(map, {
  places: [
    { id: "s1", name: "Corner Deli", lat: 51.5, lon: -0.1,
      properties: { category: "food" } },
    { id: "s2", name: "St Pancras", lat: 51.53, lon: -0.126,
      properties: { category: "travel" } },
  ],
  color: [
    "match", ["get", "category"],
    "food",       "#e63946",
    "travel",     "#457b9d",
    "postoffice", "#d90429",
    "childcare",  "#ffb703",
    /* fallback */ "#3a86ff",
  ],
  clusterColor: "#3a86ff", // clusters mix categories → plain colour only
});

A cluster mixes categories, so an expression never applies to cluster circles: they use clusterColor, which defaults to color when that is a plain string, else to MapMap signal blue. (With a custom icon, pins are images - color only styles the default circle pins.)

Programmatic selection (list → map)

select(id, options?) syncs a results list to the map: it opens the configured popup at the place (popup: false to skip) and eases the camera to it (flyTo: false to skip; zoom to also zoom in). It returns the Place, or undefined for an unknown id (nothing happens). It does not call onPlaceClick - programmatic selection is not a user click. deselect() closes any open popup.

Escape-hatch styling: ids

The generated MapLibre ids are public API via stores.ids{ source, points, clusters, clusterCounts, labels, pointsFallback }, so raw MapLibre calls are supported when the options don't reach far enough:

map.setPaintProperty(stores.ids.points, "circle-radius", 9);
map.queryRenderedFeatures({ layers: [stores.ids.points] });

Not every id is installed on the map at all times - the object always carries all six, but a layer only exists when its feature is switched on:

  • clusters/clusterCounts - only with cluster: true (the default).
  • labels - only with a label option (see below).
  • pointsFallback - only when icon is a record of per-place images: it is the circle layer drawn under places whose icon hasn't loaded (or doesn't exist).
  • points is a circle layer by default, or a symbol layer once a single custom icon has loaded.

Per-place labels

label: true writes each place's name beside its point; an options object tunes it:

new PlacesLayer(map, {
  places,
  label: { property: "name", size: 12, colour: "#333333", haloColour: "#ffffff" },
});

Both spellings are accepted for the two colour keys - colour/haloColour and color/haloColor (British wins if both are given) - so a dynamically-built options object can't lose its label colours to a spelling mismatch with the sibling color/clusterColor options. Labels only ever draw on unclustered points, and hide before colliding (text-optional).

Labels need the MapMap Sans Regular fontstack from the style's glyphs endpoint. Every MapMap style ships it; a theme pointed at a custom glyphs host that doesn't serve it drops the labels silently (MapLibre logs a glyph 404 and renders the points alone). The same applies to marker labels below.

class MarkersLayer - markers designed in Studio

Custom markers and labels that travel with the theme, under extra.markers (schema v1): up to 200 coloured glyph pins, plain dots or small custom images, each with an optional label. They are placed in Studio's Markers tab, so a designer can ship "here are our depots" with the style itself - no places data, no code change:

Glyph markers now render without this SDK. A published theme's glyph pins and dots are compiled INTO its style.json - an inline mm-user-markers GeoJSON source plus two symbol layers over MapMap's SDF marker sprite - so they draw in any MapLibre client that loads the style URL: new maplibregl.Map({ style }), MapLibre Native on iOS and Android, static/server-side renderers. No MapMapMap, no MarkersLayer, no code at all.

The one exception is a marker with a custom image (data: URI): a static sprite cannot carry per-theme artwork, so the compiler skips those items rather than drawing the wrong glyph in their place. They render only through MarkersLayer, below.

You still want MarkersLayer to change markers at runtime, to draw custom-image markers, or for short-text numbered pins. Use hasBakedMarkers(map) to find out whether the current style is already drawing its own.

import { MarkersLayer, markersFromThemeUrl } from "@mapmap/maps";

const markers = await markersFromThemeUrl(
  "https://api.mapmap.ai/styles/midnight-fleet-a1b2c3/theme",
);
if (markers) new MarkersLayer(map /* MapMapMap or maplibregl.Map */, markers);

For a theme object you already have (a downloaded *.theme.json), use markersFromTheme(theme). Both it and markersFromThemeUrl return undefined when the theme carries no markers block, so "no markers" stays distinguishable from "an empty designed set"; parseMarkers(value) is the same lenient parse over a raw extra.markers value.

layer.setMarkers(block);   // replace - never dropped, even mid-style-load
layer.setMarkers(undefined); // clear the markers, keep the layer alive
layer.current;             // the parsed items
layer.ids;                 // { source: "mm-user-markers", layer: "mm-user-markers" }
layer.clear();             // remove layer, source and this layer's images
layer.destroy();           // clear() + detach the style.load listener
  • One source, one layer, both id mm-user-markers (a locked contract shared with Studio and the server-side validator). ids is public API for escape-hatch styling.

  • Item fields: id (unique, ≤ 64 characters), lng/lat, icon (one of 21 glyphs, default pin; dot draws a plain circle), colour (#rrggbb, default #1a6bff), size (s/m/l = 24/32/40 px, default m), label (≤ 120 characters), image (a data: URI - png/jpeg/webp/svg+xml, base64, ≤ 64 KB decoded - drawn instead of the pin). Lengths are counted in Unicode scalars, so emoji count as one.

  • Parsing is lenient and never throws: invalid items are skipped, bad fields fall back to defaults, over-long labels are truncated. A block whose version is not 1 parses to nothing at all - a future v2 must not be silently drawn as v1 by an already-installed SDK.

  • Custom images load asynchronously: the marker draws its glyph pin until the image arrives, then swaps in place. An image MapLibre cannot rasterise keeps the pin - pass { onImageError } to hear about it rather than wondering why a logo is a blue pin:

    new MarkersLayer(map, markers, {
      onImageError: (image, error) => console.warn("marker image", image, error),
    });

    The usual cause is an SVG with no intrinsic width/height: an <img> renders it happily, createImageBitmap (what map.loadImage uses) refuses it.

  • Labels need the MapMap Sans Regular fontstack from the style's glyphs endpoint, exactly like the places labels above; a custom glyphs host without it drops the labels silently.

  • The layer survives setStyle and re-installs itself on every style.load. That is load-bearing: MapLibre diffs against a serialised style that INCLUDES runtime sources/layers, so a diffed setStyle removes mm-user-markers and the style.load that setState fires afterwards is what puts it back.

  • Against a baked style it takes over, it does not double-draw. A style compiled from a theme with markers already carries mm-user-markers plus a second layer, mm-user-markers-glyph, that this SDK never creates. When you give a MarkersLayer markers on such a style, it removes both baked layers and installs its own - so the markers appear exactly once, and custom-image and short-text pins (which the baked layers cannot carry) work as they always have. A MarkersLayer you construct and never hand markers to leaves the baked layers completely alone, so the style keeps drawing them. hasBakedMarkers(map) reports whether the current style is baked; BAKED_MARKERS_GLYPH_ID is the layer id it looks for.

class NavigationCamera

The turnkey chase cam: follows each GPS fix course-up, tilted, with the puck anchored low-centre (see "Navigation camera" below).

const camera = new NavigationCamera(map /* MapMapMap or maplibregl.Map */, {
  pitch?,          // tilt in degrees, default 60, clamped 0-85
  zoom?,           // follow zoom, default 17
  anchorY?,        // puck's vertical screen position 0-1, default 0.72
  easeMs?,         // ease per fix, default 900 (capped by the fix interval)
  autoRecentreMs?, // idle time before auto-recentre, default 6000; 0 = never
});
// pitch/zoom precedence: option > Studio design (extra.nav.camera, when the
// map is a MapMapMap built from a theme carrying one) > built-in default.

camera.follow({ lat, lon }, courseDeg?); // glide to a fix, course-up
camera.attachPuck(puck);                 // follow() then co-drives the puck
camera.overview(route.geometry);         // whole route, top-down
camera.resume();                         // back to the chase cam
camera.mode;                             // "follow" | "overview" | "free"
camera.destroy();                        // remove listeners
NavigationCamera.isSupported(map);       // false under globe projection

class AdrCheck

Direct access to the gateway's POST /adr/check tunnel compliance engine:

import { AdrCheck } from "@mapmap/maps";

const adr = new AdrCheck({ baseUrl: "https://api.mapmap.ai", apiKey: "snk_…" });
const decision = await adr.check({ hazmat: true, tunnelCode: "C", tunnelCategory: "D" });
// { status: "allowed" | "blocked", reason?, raw }

class Geocoder - search and reverse lookup

Typed access to the gateway's GET /geocode and GET /geocode/reverse endpoints - a search box or "what did the user tap" lookup needs no hand-rolled HTTP. Pass the map to reuse its baseUrl/apiKey, or { baseUrl, apiKey } to use it without a map (no MapLibre dependency):

import { Geocoder } from "@mapmap/maps";

const geocoder = new Geocoder(map); // or { baseUrl: "https://api.mapmap.ai", apiKey: "snk_…" }

// Forward: free-text → hits, best first. Bias towards the map centre.
const hits = await geocoder.geocode("tate modern", {
  bias: map.map.getCenter(),
  limit: 5,
});
// hits[0]: { lngLat: [lng, lat], name, kind, street, city, postcode,
//            categories?, details?, id?, raw }

// Reverse: point → nearest hits. kinds/categories/name are the
// first-party filters ("nearest cafe", "nearest Lloyds bank").
const places = await geocoder.reverse(evt.lngLat, { kinds: ["poi"] });

Errors surface the gateway's problem+json title/detail; a 501 is reported as "geocoding is not enabled on this deployment" (self-host without a geocoding backend), not as a bad request.

Pure helpers (no browser required)

Exported for server-side or test use - none of these touch MapLibre:

  • buildStyle({ theme, territoryTilesUrl }) → a MapLibre StyleSpecification.
  • buildRouteUrl(baseUrl, profile, points, truck?) / buildRouteQuery(truck?). Note the API key is NOT embedded in the URL: fetch it yourself and add the Authorization: Bearer snk_… header (RouteLayer does this internally).
  • parseOsrmRoute(body)ParsedRoute.
  • buildGeocodeUrl(baseUrl, query, opts?) / buildReverseGeocodeUrl(baseUrl, point, opts?) / parseGeocodeResponse(body)GeocodeHit[]. Same auth note as buildRouteUrl.
  • toLngLat / formatCoord / formatCoords - coordinate normalisation.
  • haversineDistanceM(a, b) - straight-line distance in metres.
  • placesFromGeoJSON(collection) - GeoJSON Points → Place[].
  • registerPmtilesProtocol(gl?) - install the pmtiles:// handler yourself.
  • OSM_ATTRIBUTION, FULL_ATTRIBUTION, DEFAULT_TERRITORY_TILES_URL, PALETTE_SLOTS, SOURCE_LAYERS, SIGNAL_BLUE.

Themes (MapMap Studio)

buildStyle (and MapMapMap's style option) accepts "light", "dark", or a MapMap Studio Theme document - the same theme JSON the sn-style crate compiles server-side, so styles render identically in the browser and in signed territory packages:

const map = new MapMapMap({
  container: "map",
  style: {
    name: "midnight-fleet",
    base: "dark",
    palette: { water: "#0b2038", roadMajor: "#8a6d3b" },
    layers: {
      building: { visible: false },
      "road-minor": { paint: { "line-width": 2 }, minzoom: 10 },
    },
  },
});
  • Palette slots (25): 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. The same 25 the Studio compiler and the sn-style crate use, so a theme published from Studio compiles here unchanged. Seven of them INHERIT until you set them: roadMotorway follows roadMajor, and wood grass wetland farmland sand rock follow a blend of landcover towards park/landuse/building/textSecondary. So a theme that only recolours landcover still moves its woods and grass, and a theme that predates these slots compiles byte-identically.
  • extra.nav (Studio's navigation design block) is carried by the theme file and ignored by style compilation - see "Using your Studio design" below.
  • extra.markers (Studio's custom markers & labels, schema v1) rides the same way: { "version": 1, "items": [ { "id", "lng", "lat", "icon", "colour", "size", "label", "image" } ] }, up to 200 items. The raw block is never copied into style.json, but it IS compiled into it: every glyph marker becomes an mm-user-markers GeoJSON source plus the mm-user-markers / mm-user-markers-glyph symbol layers over the SDF marker sprite (the style's sprite is set to it unless the theme names its own), so the markers render from the style URL alone in any MapLibre client. Markers with a custom image are the exception - they are left out of the bake and drawn only by MarkersLayer. Read the block with markersFromTheme(theme) / markersFromThemeUrl(url). extra as a whole is bounded at 256 KB serialised, and each marker image at 64 KB decoded - the per-image caps multiply, so a few full-size marker images will hit the block cap first.
  • Layer ids (29, paint order): 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 road-oneway housenumber road-labels water-name poi-labels mountain-peak-labels aerodrome-labels place-labels country-labels. landuse and landcover are class-tinted via a match on the feature class (hospital, school, cemetery, military, retail, grass, wood, …), blended from existing slots, except the seven terrain classes that have slots of their own (wood grass wetland farmland sand rock ice); unknown classes fall through to the plain slot colour. brunnel: tunnel roads dim in place and brunnel: bridge roads redraw above the flat network as road-bridge-casing/road-bridge. road-oneway draws direction arrows from z16 as a text glyph, so no sprite is required. The *-casing and building-outline layers take their colour from the road/roadMajor/building slots blended toward textSecondary: they are not separate palette slots, so overriding the base slot restyles the casing with it. Both casings paint beneath both road fills so a minor road's casing never crosses a major road at a junction.
  • Per-layer overrides: visible, paint/layout (per-key merge), filter (replace), minzoom/maxzoom. extra_layers (full MapLibre layers over the territory source) are inserted above the base map, below labels.
  • Unknown slots/layer ids throw with the accepted values listed.
  • See docs/STUDIO.md for the full theme document reference.

Using your Studio design

Studio's Navigation panel designs the turn-by-turn look - route line, current-position puck and banner instruction - and stores it under extra.nav in the theme JSON you download or copy. The block also survives hosted publishing: Studio's publish sends it with the theme, and the gateway serves it back from GET /styles/{id}/theme. Pass a theme file to createMap and the whole navigation UI styles itself:

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

// Route line: colour, width, opacity and casing from extra.nav.route.
const routes = new RouteLayer(map);
await routes.route({ lng: -0.1278, lat: 51.5074 }, { lng: -1.5106, lat: 52.4081 });

// Banner: colours, font size, padding, radius, max width, height and the
// lane row from extra.nav.banner.
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);

// Puck: dot + heading arrow (or your custom image) from extra.nav.puck.
const puck = new PositionPuck(map);
puck.setLocation({ lat: 51.5074, lon: -0.1278 }, 45); // heading in degrees
  • map.navDesign is the parsed extra.nav block (undefined when the theme has none); navDesignFromTheme(theme) / parseNavDesign(value) are exported for standalone use, and defaultNavDesign() matches the SDK's built-in look.

  • RouteLayer and PositionPuck pick the design up from a MapMapMap automatically; pass { design } / a second argument to override. Without a design everything renders exactly as before.

  • extra.nav.camera (optional since v1, whole block and each field) holds the drive-camera tokens: pitch (degrees, 0-85, default 60), zoom (14-20, default 17.5) and speedMps (drive speed in m/s, 2-40, default 12 - used by Studio's demo drive and route simulators; a camera following real GPS fixes ignores it). NavigationCamera reads the block's pitch/zoom as its option defaults when built from a MapMapMap; explicit options still win (option > design > built-in). Themes saved without the block load unchanged and stay "version": 1.

  • extra.nav.alerts (optional since v1) holds the safety-camera alert design: chip tokens (background, textColor, outlineColor, cornerRadius 0-24, outlineWidth 0-4), the always-present escalated pair (escalatedBackground, escalatedTextColor), iconSet (european / us / minimal / brand) with optional per-kind iconUrlOverrides, alertSound and escalatedSound from the built-in earcon library (cameraCalm, cameraUrgent, overspeedSoft, zoneEnter, zoneClear) with optional alertSoundUrl / escalatedSoundUrl overrides, mapIconSize (0.75-1.5) and mapMinZoom (9-14), corridorColor / corridorOpacity for the average-speed corridor, leadDistance (short / standard / long = 150 m / 250 m / 400 m floors), chipPosition (aboveSpeed / topLeading / topTrailing) and a kinds entry per camera kind (fixed, average, red_light, mobile_site, unknown) carrying showOnMap (browse-mode map display), showOnMapWhileNavigating (map display during guidance, default true), alertWhileDriving, showInRoutePreview, alertMode (always / whenSpeeding / never) and audioMode (off / earcon / earcon_and_speech).

    import { CameraAlertChip, alertPresentation, navCameraKind } from "@mapmap/maps";
    
    const chip = new CameraAlertChip(document.body, map.navDesign?.alerts);
    // Visual is unconditional and calm; audio is the escalation channel.
    chip.update({ kind: "fixed", limitKph: 30, distanceM: 240 }, { speeding: false });
    
    // Average-speed corridors tint the route line itself.
    routes.setAlertCorridors([corridorGeometry]);

    alertPresentation(design, alert, { speeding }) is the pure rule behind the chip: it returns whether to show it, whether to escalate, whether audio fires and the lead-distance floor in metres. alertChipContent returns just the title, subtitle and icon URL, which is what CarPlay's CPNavigationAlert and Android Auto's Alert accept - colours, radius and position do not survive a head-unit template, so styling degrades to the platform look there rather than being assumed to render. alertContrastIssues(design) runs a WCAG contrast check over both chip colour pairs (floor 4.5:1); Studio surfaces the same warnings as you pick colours.

    Sound. alertPresentation(...).soundUrl is the earcon to play for the state it just resolved - the calm sound, or its escalated variant when the driver is over the limit - so a host never picks the file itself. alertSoundUrl(design, escalated) is the same rule standalone. The SDK bundles no audio: the built-ins resolve to https://mapmap.ai/earcons/*.mp3 (public and unmetered), and navAlertSoundUrl(sound, baseUrl) re-points them at your own host.

    const p = alertPresentation(design, alert, { speeding });
    if (p.audible) {
      const audio = new Audio(p.soundUrl);
      audio.volume = 1; // the app owns the in-car level, not the theme
      void audio.play()?.catch?.(() => {});
    }

    There are exactly two sounds, and that is deliberate. Which camera it is travels in the icon and in the spoken line; how urgent it is travels in the timbre. Per-kind chimes would ask a driver to learn five sounds at motorway speed, which no shipping product does.

    Camera POIs on the map. Cameras are not in the vector tiles - they come from the gateway's POST /v1/cameras/along, behind the same server-side jurisdiction policy - so a style draws them over a GeoJSON source you add. cameraSymbolLayer(design, sourceId, mode, variant) builds that layer: sprite icons per kind, the design's mapIconSize and mapMinZoom, and a filter holding only the kinds that mode shows.

    map.addSource("cameras", { type: "geojson", data: camerasGeoJson });
    map.addLayer(cameraSymbolLayer(design, "cameras", "navigating", "light"));

    The icons ship in the published sprite sheet (https://api.mapmap.ai/sprite/sprite), generated from the same glyphs the chip draws, so the pin and the chip are never two different cameras. Image ids come from cameraSpriteName(kind, variant) - camera-fixed-dark, camera-redlight-light and so on, -dark for light basemaps and -light for dark ones. Your style must carry a sprite URL for them to render; the skeleton ships none by default.

    cameraShownOnMap(design, kind, mode) is the underlying rule. "browse" reads showOnMap and "navigating" reads showOnMapWhileNavigating, because showing a camera while planning and showing it while driving are separate decisions - the market warns with or without a planned route, so map display is not gated behind guidance.

    Jurisdiction policy is server side and is not styleable. Whether a camera may be returned at all is decided per country by the gateway before the response leaves it. This block styles what policy already permits; it cannot widen it.

  • The nav block travels with the theme document, never the compiled style: extra round-trips through POST /styles and GET /styles/{id}/theme (bounded at 256 KB serialised), but a compiled style.json URL alone never carries it. For a hosted style, fetch the design from the theme endpoint (public, uncached):

    import { navDesignFromThemeUrl } from "@mapmap/maps";
    
    const design = await navDesignFromThemeUrl(
      "https://api.mapmap.ai/styles/midnight-fleet-a1b2c3/theme",
    );
    const banner = new GuidanceBanner(document.body, design?.banner);
    const routes = new RouteLayer(map, { design: design?.route });

    navDesignFromThemeUrl(url, fetchImpl?) returns undefined when the theme has no nav block and throws on HTTP/network failure; pass your own fetch for tests or polyfills.

  • Parsing is lenient and clamped (widths 0.5-20, opacity 0-1, banner height 40-120 or unset for auto, puck images https:/data: only, data payloads capped at 64 KB) - a malformed block falls back per-field to the defaults rather than failing.

Cinematic flythrough and scrollytelling

flythrough(map, route, options?) replays a route as a chase-cam ride: the camera glides along the line, bearing eased along the shortest arc towards a look-ahead point, at a configurable pitch/zoom/speed.

import { flythrough, bindFlythroughToScroll } from "@mapmap/maps";

const route = await routes.route(from, to);
const replay = flythrough(map, route, { pitch: 60, durationMs: 15000 });
replay.onProgress((t) => scrubber.value = String(t));
replay.play(); // also: pause(), stop(), seek(0..1), speed = 2

// Scrollytelling: scrolling the story column scrubs the camera.
const unbind = bindFlythroughToScroll(replay, document.querySelector("#story")!);

Options: pitch (default 60), zoom (16), durationMs (20 000) or speedMps (ground speed; wins over duration), lookAheadM (200), bearingEase (3). Accepts a ParsedRoute or a GeoJSON LineString. Nothing moves until play()/seek(). flythroughPose, bearingBetween and shortestArcDelta are exported for apps driving the camera themselves.

Route effects (the flowing ribbon)

map.setRouteEffect("flow") draws an animated energy ribbon along the active route line - a MapLibre custom layer with first-party GLSL, no extra dependencies.

map.setRouteEffect("flow");                          // uses the RouteLayer route
map.setRouteEffect("flow", { color: "#ff7a1f", width: 12, speed: 0.8 });
map.setRouteEffect("flow", { geometry });            // explicit geometry
map.setRouteEffect(null);                            // plain line again
  • Attaches automatically to whatever a RouteLayer on the map draws (current and future routes), and dies with routes.clear().
  • Themes can request it: a Studio theme with "effects": { "route": "flow", "params": { … } } compiles to metadata["mapmap:effects"] on the style, and the map auto-enables the ribbon. An explicit setRouteEffect(…) call (including null) wins.
  • prefers-reduced-motion freezes the ribbon to a static gradient.
  • If WebGL setup for the effect fails, it falls back silently to the plain route line (one console warning).

Walkability rings (isochrones)

Reachability contours from the gateway's POST /isochrone:

import { IsochroneLayer } from "@mapmap/maps";

const rings = new IsochroneLayer(map); // baseUrl/apiKey from the map
await rings.showReachability({
  origin: { lat: 51.5074, lon: -0.1278 },
  mode: "walk",           // "walk" | "cycle" | "drive" | "truck" | raw costing
  minutes: [5, 10, 15],
  color: "#3a86ff",       // optional
});
rings.clear();

Renders graduated-opacity fills (nearest ring strongest), contour outlines and "N min" labels, survives theme swaps, and resolves to the raw GeoJSON FeatureCollection (each feature carries a contour property in minutes). costingOptions forwards Valhalla costing options verbatim (e.g. { pedestrian: { use_lit: 1.0 } }).

Self-diagnosing errors

The map diagnoses the classic silent failures at construction and logs ONE actionable console.error per issue per page, each with a docs link:

  • [container-zero-height] - the 0px container (top blank-map cause). Watch for CSS cascade layers here: Tailwind v4 utilities live in a layer, unlayered maplibre-gl.css does not, so absolute inset-0 on the container silently loses to .maplibregl-map { position: relative }. Use inline styles for the container's position/size, or import maplibre-gl.css into a CSS layer.
  • [container-detached] - container not in the DOM
  • [duplicate-maplibre] - two maplibre-gl copies on one page
  • [webgl-unavailable] - no WebGL 2 context available. MapLibre GL JS 6 requires WebGL 2 and has no WebGL 1 fallback.
  • [invalid-api-key] - a 401 from the gateway (missing/mistyped/revoked key)

runMapDiagnostics(…) is exported for apps wrapping a raw maplibregl.Map.

The MapMap mark and the attribution

Maps render a small MapMap wordmark bottom-right (the same convention as Google Maps and Mapbox). It links to mapmap.ai, needs no network fetch, and is always on: pass logo: { position, href } to createMap to move or relink it.

Neither the mark nor the attribution can be switched off:

  • The MapMap mark. Every displayed MapMap map carries it, which is a condition of the @mapmap/maps licence (the same model as Mapbox GL JS v2+). logo: false still type-checks, so nothing stops compiling, but it is ignored and the SDK logs one console.warn per map.
  • The OpenStreetMap attribution. The credit is a condition of the ODbL that the map data is served under, for MapMap and for you. mapOptions: { attributionControl: false } is ignored the same way. Everything else MapLibre offers still works: attributionControl: { compact: true } collapses it, and attributionControl: { customAttribution: "© Your Co" } adds your own credit alongside the OSM one.

The default layout is the canonical one: OpenStreetMap attribution in small print bottom-left, the MapMap mark bottom-right. Pass attributionPosition: "bottom-right" | "bottom-left" | "top-left" | "top-right" to createMap to mount the credit in another corner; there is no need to dig the control out of the map's internals to move it. The two are never in the same corner, because stacking the mark on the credit makes a line the ODbL requires unreadable. The SDK enforces that: if the attribution's corner and the mark's corner collide (via attributionPosition, logo.position or both), the mark moves to the opposite bottom corner and one console.warn is logged for that map.

Turn-by-turn guidance

import { GuidanceBanner, extractGuidance, speak } from "@mapmap/maps";

const route = await routes.route(from, to, {
  profile: "truck",
  voice: true,
  banner: true,
  language: "en-GB",
});

const steps = extractGuidance(route);
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);
// Pass the step as the second argument so ferry legs get the ferry glyph
// and u-turns point the right way in left-hand-traffic regions.
banner.update(steps[0].banners[0] ?? null, steps[0]);
// As the driver advances, fire each voice prompt once when its
// distanceAlongGeometry trigger is crossed:
speak(steps[0].voice[0]);

Voice prompts arrive plain and as SSML. The banner renders the manoeuvre glyph from the platform's direction-icon set (roundabouts, forks, ramps, ferries, see below) sized to the design's font, and, where OSM has turn:lanes data, a lane diagram (sprite icons lane-left, lane-straight, … ship with the map assets). The glyph's secondary shapes retheme via the --mm-icon-secondary CSS custom property.

Direction icons (@mapmap/maps/direction-icons)

The platform's 90-icon manoeuvre set as inline SVG, plus the OSRM maneuver mapping. Available from the main entry and as a separate @mapmap/maps/direction-icons entry point; apps that use neither GuidanceBanner nor the icons tree-shake the set away entirely:

import {
  directionIcons,          // { turn_left: "<svg…>", roundabout_right: …, ferry: … }
  iconNamesForSteps,       // (steps) => DirectionIconName[], preferred for a turn list
  iconNameForStep,         // (step) => DirectionIconName
  iconNameForManeuver,     // (type?, modifier?, drivingSide?) => DirectionIconName
  directionIconSvg,        // shorthand: the SVG for a maneuver
} from "@mapmap/maps/direction-icons";

const icons = iconNamesForSteps(leg.steps);
el.innerHTML = directionIcons[icons[i]];

Icons are 20×20 viewBox SVGs. The arrow inherits currentColor; secondary road furniture (other lanes, the rest of a roundabout ring, ferry water) is filled with var(--mm-icon-secondary, #C9CDD2): set that CSS custom property to retheme it. Unknown maneuvers degrade to the plain turn family, never a missing icon.

Rendering a turn list? Use iconNamesForSteps(steps). A roundabout's icon has to be read from the manoeuvre's THROUGH angle (the turn between the road you arrive on and the road you leave on) and no single step carries that: the enter step knows the approach, the matching exit step knows the road taken. Resolve them together and consecutive roundabouts render distinct icons; resolve them one at a time and every roundabout on the route draws the same generic ring. (Stock OSRM does send a roundabout modifier, but it measures the entry tangent, which is "left" for every roundabout in a left-hand-traffic country, so iconNamesForSteps deliberately overrides it.)

For a single step, iconNameForStep(step) reads everything off the OSRM step itself: ferry legs from step.mode, and the u-turn direction from step.driving_side (the MapMap gateway emits it on u-turn steps), so left-hand-traffic markets render uturn_right with no configuration. The lower-level iconNameForManeuver remains for callers without a step object; it defaults to right-hand traffic.

Navigation camera

NavigationCamera gives the Google-Maps-style chase cam in one call per fix: tilted (pitch 60), course-up, the puck anchored low-centre so the camera looks up the road, each fix gliding into the next. Feed it from the same loop that drives your guidance:

import { NavigationCamera, PositionPuck } from "@mapmap/maps";

const camera = new NavigationCamera(map, { pitch: 60, zoom: 17 });
camera.attachPuck(new PositionPuck(map)); // one call now moves both
navigator.geolocation.watchPosition(({ coords }) =>
  camera.follow({ lat: coords.latitude, lon: coords.longitude }, coords.heading ?? undefined),
);
  • Smoothness: every follow() is an interruptible linear easeTo whose duration is easeMs capped by the observed fix interval, so a fast fix cadence glides continuously instead of queueing animations.
  • User gestures win: any drag / rotate / pitch / zoom switches the camera to "free" mode (easing stops, fixes keep being recorded and the puck keeps moving). It recentres itself after autoRecentreMs of idle (default 6 s; 0 disables it - call camera.resume() yourself).
  • Overview: camera.overview(route.geometry) fits the whole route top-down (pitch 0, north-up); camera.resume() returns to the chase cam at the last fix. camera.mode reports "follow" | "overview" | "free".
  • Globe projection: NavigationCamera.isSupported(map) returns false when the map runs the globe (or vertical-perspective) projection - the low-anchor offset maths and overview framing assume a mercator camera. Switch to mercator (map.map.setProjection({ type: "mercator" })) before navigating.
  • Pitch above 60 is allowed (clamped at 85) and raises the map's maxPitch for you, but MapLibre marks it experimental and DOM-marker pucks flatten at extreme tilt - stay at 60 unless you have a reason.

Tiles, attribution and the verifying key

The default light/dark styles load MapMap territory tiles as PMTiles over the pmtiles:// protocol. Override the source with territoryTilesUrl (a pmtiles://… or plain https://… URL - the prefix is added for you).

© OpenStreetMap contributors © OpenMapTiles attribution is legally required (ODbL + CC-BY 4.0) and is baked into every compiled style's tile source. It is not themable - no theme field can remove it.

MapMap territory packages (the offline .snpkg bundles the mobile SDKs consume) are ed25519-signed and the app pins the factory public key. This web SDK renders tiles served by the gateway/CDN over HTTPS and does not verify package signatures in the browser; the signing/verifying-key story for the signed territory channel is documented in docs/SDK-DISTRIBUTION.md and docs/TERRITORY-UPDATES.md.

Bundler notes

  • ESM only. Ships dist/index.js + dist/index.d.ts.
  • maplibre-gl and pmtiles stay external; provide them in your app.
  • No bundler? See examples/index.html - it uses a native import map and the unpkg ESM CDN.

Develop

npm install
npm run typecheck   # tsc --noEmit
npm run build       # tsup → dist/ (ESM + .d.ts)
npm test            # vitest (pure-logic unit tests, no browser)

Licence

Proprietary - © 2026 Mapmap AI Ltd, distributed under a commercial licence. See LICENSE.

3D terrain

createMap({ container: "map", terrain: true });
createMap({ container: "map", terrain: { exaggeration: 1.4, hillshade: true } });
map.setTerrain(false);

The default DEM is the same dataset the gateway samples for POST /elevation, so rendered terrain and elevation queries agree. The SDK re-applies terrain across style swaps, gives hillshade its own DEM source (sharing one causes artefacts) and carries the DEM attribution. Note that terrain displaces by ABSOLUTE elevation: custom layers drawing in a local frame must add map.queryTerrainElevation(...) at their anchor.