Documentation menu
SDKs & installation
Five SDKs over one Rust core. The mobile packages ship from tagged
releases (sdk-v*); the npm packages can also be cut on their own between
tags, so their versions run ahead. Fleet customers licence the on-device
SDK per vehicle; app developers can meter per monthly active user instead
(see pricing).
Status: the npm packages are live:
@mapmap/maps,
@mapmap/core and
@mapmap/react-native
install from npm today. The iOS package installs publicly via Swift Package
Manager from the distribution repository
Mapmapai/mapmap-ios (current
release 0.6.0). The Android AAR publishes to Maven Central (current
release 0.3.0), which needs no account and no token. The zero-install way in is the
playground; the hosted gateway at https://api.mapmap.ai is
live (sign up for a key), and the same gateway runs from the
self-host distro; base URL, auth and quotas are defined
in conventions.
Compatibility
| SDK | Package | Version | Platform floor | Peer requirements |
|---|---|---|---|---|
| Web maps | @mapmap/maps | 0.8.0 | Node ≥ 18 to build; ESM only | maplibre-gl ≥ 4 < 6, pmtiles ≥ 3 < 5 |
| Core (WASM) | @mapmap/core | 0.3.0 | Node (the npm artefact is the Node build) | none |
| React Native | @mapmap/react-native | 0.3.1 | iOS 16.4, minSdk 26; Expo SDK 52+, or bare React Native 0.74+ | expo-modules-core, react, react-native; expo ≥ 52 optional |
| Android | ai.mapmap:core | 0.3.0 | minSdk 26, JDK 17 to build | none |
| iOS | MapMapKit (Mapmapai/mapmap-ios) | 0.6.0 | iOS 16.4, Swift tools 5.9 | none |
iOS: never pin 0.2.0. The 0.2.0 iOS release shipped without its generated Swift bindings and did not compile; 0.2.1 is the packaging fix (same checksum-pinned binary core).
Web maps: @mapmap/maps
Availability: live on npm.
A thin TypeScript wrapper over MapLibre GL JS that gives you a MapMap map
with our tiles, styles and routing wired in. You need an snk_ API key;
issue one card-free in the quickstart.
npm install @mapmap/maps maplibre-gl pmtiles
The map needs MapLibre's stylesheet and a container with a real height:
<div id="map" style="height: 480px"></div>
The example below routes a hazmat truck. ADR (the European agreement on carriage of dangerous goods by road; tunnel codes B–E restrict which tunnels a hazmat load may use) is a first-class routing parameter; the full tunnel-code table is in conventions.
import "maplibre-gl/dist/maplibre-gl.css";
import { createMap, RouteLayer } from "@mapmap/maps";
const map = createMap({
container: "map",
apiKey: "snk_...", // from the quickstart
// baseUrl defaults to https://api.mapmap.ai
});
await map.whenReady();
const routes = new RouteLayer(map);
const route = await routes.route(
{ lon: -0.1278, lat: 51.5074 }, // London
{ lon: -1.8904, lat: 52.4862 }, // Birmingham
{ profile: "truck", truck: { heightM: 4.0, weightT: 40, hazmat: true, tunnelCode: "C" } },
);
route.distanceM; // total distance, metres
route.durationS; // total duration, seconds
route.geometry; // GeoJSON LineString ([lng, lat] positions), already drawn on the map
route.raw; // the raw routes[0] object, for leg/step detail
Alternatives, ferry legs and geometry you already hold
Offering a choice of routes is table stakes for a navigation UI, so
RouteLayer draws the unselected ones dimmer and beneath, clickable to
switch. The selected route keeps everything else the layer does: progress,
manoeuvre arrow, alert corridors.
routes.drawAlternatives(parsedRoutes, selectedIndex);
routes.onSelectAlternative((i) => routes.drawAlternatives(parsedRoutes, i));
// Ferry legs as dashes over the selected line, so a water crossing does
// not read as driving. Supplied explicitly, because line-dasharray cannot
// be data-driven and only you know which legs are ferries.
routes.setFerrySegments([[[lng, lat], ...]]);
// A line you already have, with no ParsedRoute to build:
routes.drawGeometry([[lng, lat], ...]);
// Runtime paint over the design, e.g. marking a route provisional or
// straight-line-approximated. Survives style reloads; `{}` clears it.
routes.setLineStyle({ color: "#9096a2", dash: [1.6, 1.4], casingOpacity: 0 });
Coordinates are accepted as [lng, lat] arrays, { lng, lat } or
{ lon, lat } objects. Profiles: driving (the default), walking and
truck; the truck options (heightM, widthM, lengthM, weightT,
hazmat, tunnelCode) only apply to the truck profile. AdrCheck (a
typed client for the gateway's POST /adr/check compliance endpoint) and
the pmtiles protocol are wired up for you.
Label language is a style option: buildStyle({ labelLanguage: "ja" })
renders labels in that language with local-name fallback ("local" shows
every place in its own language); see
label languages. PlacesLayer renders your
own places (pins, clustering, popups, on-map labels, a different icon per
place, nearest by straight line or by drive time) and pairs with the hosted
/places store-finder API.
MarkersLayer draws the markers a designer placed in Studio's Markers tab
and shipped with the theme, and new RouteLayer(map, { endpoints: true })
adds branded start, destination and numbered waypoint markers to a drawn
route.
The SDK runs from any origin: the gateway answers with
Access-Control-Allow-Origin: *, so browser calls work from ordinary web
apps and from null-origin contexts alike: Figma plugins, Electron apps,
file:// pages. Keys travel as bearer headers, never cookies, which is what
makes the open policy safe.
Errors
route() throws a plain Error. Gateway errors (401 bad key, 429 rate
limit; RFC 9457 problem+json on the wire) surface as
route request failed: HTTP <status> (<title> - <detail>); routing-engine
failures (OSRM-shaped bodies with a code) surface as
OSRM routing failed: <code> - <message>. The full error envelope,
including 402-vs-429, is in conventions.
try {
await routes.route(from, to, { profile: "truck" });
} catch (err) {
// e.g. "route request failed: HTTP 401 (...)"; check your snk_ key
// e.g. "OSRM routing failed: NoRoute - ..."; no legal route exists
console.error(err);
}
Turn-by-turn guidance
Request voice and banner instructions on the route, then use the guidance helpers:
import { extractGuidance, speak, GuidanceBanner } from "@mapmap/maps";
const route = await routes.route(from, to, {
profile: "truck",
voice: true, // spoken voiceInstructions on each step
banner: true, // visual bannerInstructions, with lane data
language: "en-GB",
});
const steps = extractGuidance(route); // one StepGuidance per step
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);
banner.update(steps[0]?.banners[0] ?? null); // render the next maneuver
if (steps[0]?.voice[0]) speak(steps[0].voice[0], { lang: "en-GB" });
Each step's voice array is ordered by descending trigger distance; speak
each instruction once as its distance is crossed; de-duplication is the
caller's responsibility.
Navigation camera
NavigationCamera is the turnkey chase cam: tilted (pitch 60, clamped
0–85), course-up, zoom 17, with the puck anchored low-centre (anchorY
0.72) so the camera looks up the road. 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),
);
Every follow(fix, courseDeg?) glides with an interruptible linear ease
(duration easeMs, default 900 ms, capped by the observed fix interval),
sets the bearing to the course (or holds it when courseDeg is omitted),
and co-drives an attached puck. Any user drag / rotate / pitch / zoom
switches the camera to "free" mode and it recentres itself after
autoRecentreMs idle (default 6 s; 0 disables; call resume()).
overview(route.geometry) fits the whole route top-down and resume()
returns to the chase cam; camera.mode reports
"follow" | "overview" | "free", and destroy() removes the listeners.
NavigationCamera.isSupported(map) returns false under the globe
projection, whose camera geometry breaks the low-anchor offset maths;
switch the map to mercator before navigating.
The PositionPuck interpolates between fixes by default: it glides
along the shortest arc from its rendered position to each new fix over a
duration matched to the observed fix interval, so the puck and camera
arrive together instead of the puck teleporting once a second. Pass
{ interpolate: false } to snap instead.
Progress line & manoeuvre arrows
Dim the travelled part of the route (the "vanishing route line") and drop a rotated arrow at the next turn; both survive a light/dark style swap:
routes.setProgress(0.42); // 0–1 of the route length, travelled
routes.setManeuver([lon, lat], 135); // arrow at the next turn, bearing°
// …later
routes.setProgress(0); // restore the plain line
routes.clearManeuver();
setProgress renders a line-gradient over the route source (built with
lineMetrics); pass a progressColor in the layer options to change the
dimmed colour. Derive the fraction from the guidance module's
distance-remaining.
Day/night & label language
Switch light/dark automatically by sun position, and labels by language;
both dependency-free and offline-capable (territory packages ship paired
light/dark styles and the OpenMapTiles multilingual name:* fields):
import { ThemeScheduler, setMapLanguage } from "@mapmap/maps";
// Flip light ↔ dark at local sunrise/sunset, re-armed each boundary.
const scheduler = new ThemeScheduler({
lat: 51.5, lng: -0.13,
onLight: () => map.setStyle("light"),
onDark: () => map.setStyle("dark"),
});
// scheduler.dispose() to stop.
setMapLanguage(map, "de"); // relabel in German; null restores the default
ThemeScheduler handles polar day/night; resolveTheme(date, lat, lng) is
the one-shot form. setMapLanguage rewrites only name-label layers (road
shields and house numbers are left alone) and validates the tag. For
right-to-left scripts, also install MapLibre's RTL text plugin in your app.
Using your Studio design
A theme designed in Studio carries its navigation look: route
line, position puck, banner and safety alerts, under extra.nav in the
theme JSON you download or copy. Pass that theme file to createMap and the 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();
const routes = new RouteLayer(map); // line colour/width/opacity + casing
const banner = new GuidanceBanner(document.body, map.navDesign?.banner);
const puck = new PositionPuck(map); // dot + heading arrow, or custom image
puck.setLocation({ lat: 51.5074, lon: -0.1278 }, 45); // heading in degrees
The alerts block (optional since v1) styles safety-camera alerts:
CameraAlertChip renders the chip in its calm and escalated states with
per-kind icons, and RouteLayer.setAlertCorridors tints average-speed
corridors on the route line.
import { CameraAlertChip } from "@mapmap/maps";
const chip = new CameraAlertChip(document.body, map.navDesign?.alerts);
chip.update({ kind: "fixed", limitKph: 30, distanceM: 240 }, { speeding: false });
The visual alert is unconditional and calm; audio is the escalation
channel, so a driver within the limit still sees the chip and hears it
only when the kind's alertMode says so. Chip positions are a fixed set
of slots rather than free coordinates, so an alert can never cover the
speed or the limit. On CarPlay and Android Auto, alerts render from the
platform's own template, so read alertChipContent for the title,
subtitle and icon and let the colours degrade gracefully.
Whether cameras may be shown at all is a server-side jurisdiction policy applied per country before the response leaves the gateway. The design styles what policy permits and cannot widen it.
map.navDesign is the parsed block (navDesignFromTheme /
parseNavDesign are exported too); RouteLayer and PositionPuck pick it
up automatically, and without a nav block everything keeps the SDK's
built-in look.
The block also survives hosted publishing: it travels with the theme
document, stored on publish, served back from GET /styles/{id}/theme
(public, uncached), while the compiled style.json never carries it.
Fetch it from a hosted style with navDesignFromThemeUrl:
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);
The map renders the attribution required by our data licence and the
MapMap mark, and neither can be removed by configuration. As of 0.8.0,
mapOptions: { attributionControl: false } and logo: false are accepted,
ignored and reported with one console.warn per map rather than honoured.
What still works: attributionControl: { compact: true } collapses the
credit, { customAttribution } adds your own alongside the OpenStreetMap
one, and logo: { position, href } moves or relinks the mark.
The package has over 200 named exports, types included: map, routing, ADR, guidance, markers, places, styling and coordinate helpers. This page shows the main ones; a generated TypeDoc reference ships with the npm release.
Core (WASM): @mapmap/core
Availability: live on npm.
The navigation core compiled to WebAssembly, for offline or worker-side use without a map: ADR tunnel compliance, offline route-request building, route parsing, polyline codecs, and the same Ferrostar-derived guidance state machine the mobile SDKs run. The npm artefact is the Node build.
npm install @mapmap/core
const { checkTunnel, buildOfflineRouteRequest } = require("@mapmap/core");
// May this load use a category D tunnel? (ADR 8.6.4, worst-case reading)
checkTunnel({ hazmat: true, tunnelCode: "C" }, "D");
// => { status: "blocked", reason: "..." } or { status: "allowed" }
// Build a turn-by-turn request any Valhalla endpoint accepts;
// hosted API, self-hosted server, or an on-device engine:
const body = buildOfflineRouteRequest(
[{ lat: 51.1279, lon: 1.3134 }, { lat: 52.4862, lon: -1.8904 }],
"truck",
{ heightM: 4.0, grossWeightT: 40, hazmat: true, tunnelCode: "D" },
);
The full surface: version(), checkTunnel(profile, category),
forbiddenCategories(profile), buildOfflineRouteRequest(locations, costing, adrProfile), parseRouteSummary(json) (returns { distanceM, durationS, hasToll, hasHighway, hasFerry }), polylineDecode /
polylineEncode (precision 5 or 6), and GuidanceSession: construct it
from a route response JSON, stream location fixes in with
updateLocation(lat, lon, timestampMs, …), and each call returns the
updated { state: "navigating" | "arrived" | "offRoute", … }.
React Native: @mapmap/react-native
Availability: live on npm, 0.3.1.
An Expo module over the same native SDKs: signed territory install and lifecycle, on-device routing with typed geometry and manoeuvres, a typed guidance event stream, and voice guidance.
npx expo install @mapmap/react-native
Register the config plugin in app.json or app.config.js. It writes the
NSLocation*UsageDescription keys the SDK's location provider needs (the
SDK declares none of its own) and enables the location and audio
background modes, so guidance and voice keep running with the screen off:
{
"expo": {
"plugins": [
["@mapmap/react-native", {
"locationWhenInUsePermission": "Shown when asking for foreground location.",
"locationAlwaysPermission": "Shown when asking for background location."
}],
["expo-build-properties", { "android": { "minSdkVersion": 26 } }]
]
}
}
Then build a dev client with npx expo run:ios or npx expo run:android.
minSdk 26 again. The same floor as the Android SDK, and above Expo's default, so the
expo-build-propertiesline above is not optional.
Not available in Expo Go. The native module needs a dev client. Pass
truetogetMapmapNavfor a pure TypeScript simulator with an identical surface, which is what Expo Go, unit tests and Storybook-style UI work should use. Importing the package never requires the native module: the native handle resolves lazily, on first use.
import { getMapmapNav, addGuidanceListener } from "@mapmap/react-native";
const nav = getMapmapNav(false); // true → the mock
await nav.init("snk_…");
await nav.installTerritory("uk"); // signed, verify-then-promote
const { routes } = await nav.computeRoute({
profile: "car",
origin: { latitude: 51.5074, longitude: -0.1278 },
destination: { latitude: 51.5081, longitude: -0.0759 },
});
On bare React Native, install expo-modules-core alongside it (that peer is
required; the expo package itself is optional and never imported at
runtime), follow Expo's
bare install guide,
set minSdkVersion 26, and apply the Info.plist keys and background modes
by hand, since the config plugin only runs under Expo prebuild.
Android (Kotlin)
Availability:
ai.mapmap:core0.3.0, published to Maven Central; no account and no token needed. The 0.2.x releases remain on GitHub Packages only. Full class-by-class API on the Android API reference.
minSdk 26. The SDK requires
minSdk 26(Android 8.0). That is above the default of many app templates; Expo projects in particular default lower, so adding the SDK forces aminSdkVersionbump in your app (anapp.json/expo-build-propertieschange on Expo).
0.3.0 resolves from mavenCentral() with no credentials. Only the 0.2.x
releases still need GitHub Packages, which requires authentication even for
public packages: a GitHub account with access to the repository and a
personal access token with the read:packages scope, supplied via
~/.gradle/gradle.properties (gpr.user / gpr.token) or environment
variables.
Declare the repository at the root of the build, not in a module.
Projects created from current Android/Gradle templates use
dependencyResolutionManagement in settings.gradle(.kts); once that
block exists, a repositories {} block inside a module's
build.gradle(.kts) is silently ignored (unless the project opts into
FAIL_ON_PROJECT_REPOS), and dependency resolution never searches your
repo. This especially bites library/RN-module authors: your module cannot
supply the repository; the host app's root project must declare it.
// settings.gradle.kts: root project, inside dependencyResolutionManagement
dependencyResolutionManagement {
repositories {
google()
mavenCentral() // 0.3.0 and later; nothing else to add
// Only needed to resolve an 0.2.x release:
// maven {
// url = uri("https://maven.pkg.github.com/Mapmapai/mapmap")
// credentials {
// username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
// password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
// }
// }
}
}
// app/build.gradle.kts
dependencies {
implementation("ai.mapmap:core:0.3.0")
}
The offline territory store takes the pinned verifying key at construction (see "The verifying key" below):
import ai.mapmap.territory.TerritoryStore
// 64-hex factory verifying key, baked in at build time (e.g. BuildConfig);
// never fetched at runtime.
val store = TerritoryStore(
rootDir = File(context.filesDir, "territories"),
verifyingKeyHex = BuildConfig.MAPMAP_FACTORY_PUBKEY_HEX,
)
Every install is verify-then-promote: a package that fails signature verification leaves no trace on disk.
Android Auto
The :car and :car-maplibre modules put the same guidance state on the
car's own display: a NavigationManagerBridge for the Car App Library's
NavigationManager, plus a CarMapViewHost that projects your MapLibre
MapView onto the car surface. Beta; see
CarPlay & Android Auto for the entitlement/Play Console
gates, a full quickstart and DHU testing.
iOS (Swift)
Availability: installs publicly via Swift Package Manager from the distribution repository
Mapmapai/mapmap-ios. Current release 0.6.0; pinfrom: "0.6.0", never 0.2.0 (0.2.0 shipped without its generated Swift bindings and did not compile; 0.2.1 is the fix, same binary core).
The SDK is MapMapKit: a Swift package whose FFI layer is a prebuilt
binary XCFramework (UniFFI bindings over the same Rust core as Android),
referenced as a checksummed binaryTarget pinned per release. Add the
MapMapKit product to your target; an optional MapMapValhalla
product adds the on-device Valhalla routing engine (iOS 16.4+).
// Package.swift
dependencies: [
.package(url: "https://github.com/Mapmapai/mapmap-ios", from: "0.6.0"),
],
// In your target's dependencies:
.product(name: "MapMapKit", package: "mapmap-ios"),
.product(name: "MapMapValhalla", package: "mapmap-ios"), // opt-in on-device routing
import MapMapKit
let territoryDir = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("territories")
// 64-hex factory verifying key, pinned at build time.
let store = TerritoryStore(rootDir: territoryDir, verifyingKeyHex: mapMapFactoryPubkeyHex)
Requires iOS 16.4 and Swift tools 5.9.
CarPlay
The optional MapMapCarPlay product orchestrates the CarPlay side end to
end: the CPMapTemplate root, browse → preview → navigate activity
states and the CPNavigationSession lifecycle, driven from the same
NavigationEngine output. Beta; see
CarPlay & Android Auto for the required Apple entitlement, a
full quickstart and CarPlay Simulator testing.
The verifying key: trust anchor for offline packages
Territory packages (the offline maps the mobile SDKs consume; see
territories) are ed25519-signed by the factory signing
key, and each app pins the factory's 64-hex verifying key (the public
half of that pair) so a tampered or substituted package is rejected on
device. The pinning call is the TerritoryStore constructor shown in the
Android and iOS sections above: bake the key in at build time (a build
config field or bundled resource); never fetch it over the same channel as
the packages it validates.
Where the key comes from: the hosted channel's verifying key is published
in the territories docs;
it is a public key, safe to embed in any app; self-host
operators generate their own key pair with snfactory keygen and pin their
own public key, which is exactly what makes a deployment yours: it runs
entirely on your infrastructure.
The web SDK streams tiles from the API rather than verifying packages in the browser, so pinning applies to the mobile SDKs.
Licensing: per vehicle or per MAU
The on-device SDK is licensed per vehicle per month for fleets. If you are
building an app rather than running a fleet, the same SDKs can be metered
per monthly active user instead: your app sends an opaque X-MapMap-User
header on its gateway requests (8–128 printable ASCII characters; hash an
install id; never send PII, and the gateway hashes the value again before
storing it), and the gateway counts distinct identifiers per calendar
month. No header means no MAU counting, which is the normal state for
server-to-server API use. The SDKs do not yet set this header
automatically; send it from your app's networking layer:
curl -fsS -H "Authorization: Bearer $API_KEY" \
-H "X-MapMap-User: 9f2c4a1e8b7d3f60" \
"$BASE/route/v1/truck/1.3134,51.1279;-1.8904,52.4862"
Billing is fail-open by design: when a new user cannot be covered by your
prepaid balance the request is never refused; the account is marked
mau_overdrawn for the month (visible on GET /v1/keys/self) and the
gateway keeps serving, so your users are never interrupted by your
account's billing state. Numbers on pricing.
Next steps
- Android API reference: every public class and method of
ai.mapmap:core - File formats: channel index, package layout,
.drive.jsonlreplay corpus - Quickstart: issue an
snk_key and route a truck in two calls - Conventions: base URL, auth, error envelope, ADR tunnel codes
- Playground: try routing with zero install
- Self-host: run the gateway and map factory yourself