@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.
Maintainers
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 pmtilesmaplibre-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, secondsThe 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 underlyingmaplibregl.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 Studioextra.navblock when thestyletheme 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 listenersClicking 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 withcluster: true(the default).labels- only with alabeloption (see below).pointsFallback- only wheniconis a record of per-place images: it is the circle layer drawn under places whose icon hasn't loaded (or doesn't exist).pointsis a circle layer by default, or a symbol layer once a single customiconhas 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 Regularfontstack from the style'sglyphsendpoint. 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 inlinemm-user-markersGeoJSON 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. NoMapMapMap, noMarkersLayer, 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 throughMarkersLayer, below.You still want
MarkersLayerto change markers at runtime, to draw custom-image markers, or for short-text numbered pins. UsehasBakedMarkers(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 listenerOne source, one layer, both id
mm-user-markers(a locked contract shared with Studio and the server-side validator).idsis public API for escape-hatch styling.Item fields:
id(unique, ≤ 64 characters),lng/lat,icon(one of 21 glyphs, defaultpin;dotdraws a plain circle),colour(#rrggbb, default#1a6bff),size(s/m/l= 24/32/40 px, defaultm),label(≤ 120 characters),image(adata: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
versionis not1parses 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(whatmap.loadImageuses) refuses it.Labels need the
MapMap Sans Regularfontstack from the style'sglyphsendpoint, exactly like the places labels above; a custom glyphs host without it drops the labels silently.The layer survives
setStyleand re-installs itself on everystyle.load. That is load-bearing: MapLibre diffs against a serialised style that INCLUDES runtime sources/layers, so a diffedsetStyleremovesmm-user-markersand thestyle.loadthatsetStatefires 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-markersplus a second layer,mm-user-markers-glyph, that this SDK never creates. When you give aMarkersLayermarkers 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. AMarkersLayeryou 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_IDis 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 projectionclass 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 MapLibreStyleSpecification.buildRouteUrl(baseUrl, profile, points, truck?)/buildRouteQuery(truck?). Note the API key is NOT embedded in the URL: fetch it yourself and add theAuthorization: Bearer snk_…header (RouteLayerdoes this internally).parseOsrmRoute(body)→ParsedRoute.buildGeocodeUrl(baseUrl, query, opts?)/buildReverseGeocodeUrl(baseUrl, point, opts?)/parseGeocodeResponse(body)→GeocodeHit[]. Same auth note asbuildRouteUrl.toLngLat/formatCoord/formatCoords- coordinate normalisation.haversineDistanceM(a, b)- straight-line distance in metres.placesFromGeoJSON(collection)- GeoJSON Points →Place[].registerPmtilesProtocol(gl?)- install thepmtiles://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 thesn-stylecrate use, so a theme published from Studio compiles here unchanged. Seven of them INHERIT until you set them:roadMotorwayfollowsroadMajor, andwoodgrasswetlandfarmlandsandrockfollow a blend oflandcovertowardspark/landuse/building/textSecondary. So a theme that only recolourslandcoverstill 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 intostyle.json, but it IS compiled into it: every glyph marker becomes anmm-user-markersGeoJSON source plus themm-user-markers/mm-user-markers-glyphsymbol layers over the SDF marker sprite (the style'sspriteis 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 customimageare the exception - they are left out of the bake and drawn only byMarkersLayer. Read the block withmarkersFromTheme(theme)/markersFromThemeUrl(url).extraas a whole is bounded at 256 KB serialised, and each markerimageat 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.landuseandlandcoverare class-tinted via amatchon the featureclass(hospital, school, cemetery, military, retail, grass, wood, …), blended from existing slots, except the seven terrain classes that have slots of their own (woodgrasswetlandfarmlandsandrockice); unknown classes fall through to the plain slot colour.brunnel: tunnelroads dim in place andbrunnel: bridgeroads redraw above the flat network asroad-bridge-casing/road-bridge.road-onewaydraws direction arrows from z16 as a text glyph, so no sprite is required. The*-casingandbuilding-outlinelayers take their colour from theroad/roadMajor/buildingslots blended towardtextSecondary: 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 theterritorysource) are inserted above the base map, below labels. - Unknown slots/layer ids throw with the accepted values listed.
- See
docs/STUDIO.mdfor 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 degreesmap.navDesignis the parsedextra.navblock (undefinedwhen the theme has none);navDesignFromTheme(theme)/parseNavDesign(value)are exported for standalone use, anddefaultNavDesign()matches the SDK's built-in look.RouteLayerandPositionPuckpick the design up from aMapMapMapautomatically; 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) andspeedMps(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).NavigationCamerareads the block's pitch/zoom as its option defaults when built from aMapMapMap; 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,cornerRadius0-24,outlineWidth0-4), the always-present escalated pair (escalatedBackground,escalatedTextColor),iconSet(european/us/minimal/brand) with optional per-kindiconUrlOverrides,alertSoundandescalatedSoundfrom the built-in earcon library (cameraCalm,cameraUrgent,overspeedSoft,zoneEnter,zoneClear) with optionalalertSoundUrl/escalatedSoundUrloverrides,mapIconSize(0.75-1.5) andmapMinZoom(9-14),corridorColor/corridorOpacityfor the average-speed corridor,leadDistance(short/standard/long= 150 m / 250 m / 400 m floors),chipPosition(aboveSpeed/topLeading/topTrailing) and akindsentry per camera kind (fixed,average,red_light,mobile_site,unknown) carryingshowOnMap(browse-mode map display),showOnMapWhileNavigating(map display during guidance, default true),alertWhileDriving,showInRoutePreview,alertMode(always/whenSpeeding/never) andaudioMode(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.alertChipContentreturns just the title, subtitle and icon URL, which is what CarPlay'sCPNavigationAlertand Android Auto'sAlertaccept - 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(...).soundUrlis 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 tohttps://mapmap.ai/earcons/*.mp3(public and unmetered), andnavAlertSoundUrl(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'smapIconSizeandmapMinZoom, 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 fromcameraSpriteName(kind, variant)-camera-fixed-dark,camera-redlight-lightand so on,-darkfor light basemaps and-lightfor dark ones. Your style must carry aspriteURL for them to render; the skeleton ships none by default.cameraShownOnMap(design, kind, mode)is the underlying rule."browse"readsshowOnMapand"navigating"readsshowOnMapWhileNavigating, 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:
extraround-trips throughPOST /stylesandGET /styles/{id}/theme(bounded at 256 KB serialised), but a compiledstyle.jsonURL 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?)returnsundefinedwhen the theme has no nav block and throws on HTTP/network failure; pass your ownfetchfor 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
RouteLayeron the map draws (current and future routes), and dies withroutes.clear(). - Themes can request it: a Studio theme with
"effects": { "route": "flow", "params": { … } }compiles tometadata["mapmap:effects"]on the style, and the map auto-enables the ribbon. An explicitsetRouteEffect(…)call (includingnull) wins. prefers-reduced-motionfreezes 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, soabsolute inset-0on 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/mapslicence (the same model as Mapbox GL JS v2+).logo: falsestill type-checks, so nothing stops compiling, but it is ignored and the SDK logs oneconsole.warnper 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, andattributionControl: { 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 lineareaseTowhose duration iseaseMscapped 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 afterautoRecentreMsof idle (default 6 s;0disables it - callcamera.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.modereports"follow" | "overview" | "free". - Globe projection:
NavigationCamera.isSupported(map)returnsfalsewhen 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
maxPitchfor 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-glandpmtilesstay 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.
