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.12.0 | Node ≥ 18 to build; ESM only | maplibre-gl ≥ 5 < 7, pmtiles ≥ 3 < 5 |
| Point clouds | @mapmap/points | 0.5.0 | Node ≥ 18 to build; ESM only | maplibre-gl ≥ 5.6 < 6 or ≥ 6.9 < 7, three ≥ 0.160 < 1, laz-perf ≥ 0.0.7 optional (COPC only) |
| Core (WASM) | @mapmap/core | 0.3.0 | Node (the npm artefact is the Node build) | none |
| React Native | @mapmap/react-native | 0.3.2 | 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. IsochroneLayer renders reachability rings from the gateway's
POST /isochrone in one call, and map.map (the raw MapLibre map) takes
any layer the MapLibre style spec can draw: the
add your own data guide covers all of it, with worked
choropleth and heatmap examples.
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.
Point clouds: @mapmap/points
Availability: live on npm, 0.5.0. That is the version this page documents and the version this site itself runs, so
npm install @mapmap/pointsgets you the streaming COPC path below. 0.2.0 was never cut as a release of its own, so its additions ship inside 0.3.0, and 0.4.0 was a streaming-path hardening release on top of it: newopenCopcand layer options, all off by default. 0.5.0 adds MapLibre GL JS 6 support, and reconciles the package with the repository while it is there: the 0.4.0 on npm was published from a feature branch that never reachedmain, whilemainseparately carried the clearance module, so 0.5.0 is the first release in which the package on npm and the package in the repository are the same thing. Nothing 0.4.0 shipped was dropped, and the 34 clearance names documented below are published for the first time. Everything any of these versions added is additive: 0.1.0 code compiles and renders unchanged against 0.5.0, with the exceptions the changelog shipped in the package (node_modules/@mapmap/points/CHANGELOG.md) lists under Changed and Fixed. On 0.5.0 those are the narrowed peer range (maplibre-gl^5.6.0 || ^6.9.0, so 4.x, 5.0 to 5.5 and 6.0 to 6.8 are out), WebGL 2 being required on MapLibre 6, andthreemoving to 0.186.
LiDAR and photogrammetry point clouds rendered inside the map's own WebGL context, one draw call, compositing with the basemap, terrain and every other layer, rather than floating in a separate canvas on top.
npm install @mapmap/points three maplibre-gl
import { classesFromMeta, createPointCloudLayer } from "@mapmap/points";
const [meta, raw] = await Promise.all([
fetch("/cloud.json").then((r) => r.json()),
fetch("/cloud.bin").then((r) => r.arrayBuffer()),
]);
map.on("style.load", () =>
map.addLayer(
createPointCloudLayer({ meta, raw }, { classes: classesFromMeta(meta.classes) })
)
);
Then drive it from your own UI: setColourMode("rgb" | "class" | "height"),
setClassVisible(byte, on), setPointSize(metres),
setHeightRange(minM, maxM), getFps(). getClasses() returns the
resolved table, which is what you build a legend from, and
getHeightRange() returns the band the height ramp currently spans, which
is where a slider starts.
The table's visible array is live: it is the array the shader itself
reads, so it always reflects the last setClassVisible or
setAllClassesVisible call, and a legend reads its checked state straight
off the layer rather than shadowing the filter state alongside it.
getVisiblePointCount() is the matching number for a stat readout: how
many points are on screen right now. That is meta.count for a layer with
no class table, since nothing is being culled, and otherwise the sum of
meta.classCounts over the visible classes. It is null when the sidecar
carries no classCounts, because the only other way to answer is to walk
the class byte of every point in the payload on the main thread, which is
far too expensive to do on every toggle for a number the producer could
have baked in.
The format: 10 bytes per point
One JSON sidecar and one binary blob:
[ count * 3 * Uint16 ] positions, little-endian
[ count * 4 * Uint8 ] colour RGB + class in the alpha byte
The naive encoding (float64 ordinates, float colours) is about 48 bytes
per point, which puts a million-point corridor at 48 MB before compression.
More importantly, these two blocks reach the GPU untouched: decoding is two
typed-array views over the downloaded ArrayBuffer, with no copy and no
per-point loop, and the shader dequantises.
Positions are integers in units of quant metres from origin, in a local
east/up/south frame. At the default 2 cm a Uint16 spans 1.3 km per axis.
validateMeta fails a sidecar whose extent will not fit, because the
alternative is a cloud silently folded back on itself.
The class rides in the alpha byte rather than a fourth attribute buffer: a
Uint8x4 colour attribute is one interleaved upload, and alpha is dead
weight in an opaque cloud.
encodePointCloud exists for clouds built in the browser; production baking
usually happens offline.
The sidecar also carries two free-form provenance strings the SDK never
renders. source is attribution; sensor is what captured the cloud, such
as "Ouster OS-1-256-RGB @ 2048x10", so a viewer's info panel does not have
to parse it back out of source or duplicate it in app config. Both are
carried through encode, decode and validation untouched.
Classes are yours
Every point carries one class byte, and this SDK has no opinion about what those bytes mean. A survey vendor, a national mapping agency and an ASPRS LAS file all number their classes differently, and a renderer that assumes one of them silently mislabels the other two.
So the class table is an input:
createPointCloudLayer({ meta, raw }, {
classes: [
{ value: 2, label: "ground", colour: "#6b6f76" },
{ value: 6, label: "building", colour: "#dbd2c0" },
{ value: 40, label: "overhead wire" }, // default palette colour
],
});
Your byte values survive as your byte values. 40 stays 40, it is not
reindexed to 2. The shader is generated per class table, which is what
makes that possible. Duplicate bytes, values outside 0–255 and tables over
MAX_CLASSES (16, a bounded uniform scan with a compile-time trip count)
throw, because each of those otherwise renders as points quietly taking a
neighbour's colour with nothing on screen to explain it.
Omit classes and you get RGB and height colouring; setColourMode("class")
then throws rather than inventing a taxonomy.
classesFromMeta(meta.classes) builds that table from a sidecar, and reads
either shape a producer might have written: a map of class byte to label,
{ "2": "ground", "6": "building" }, or a list of entries,
[{ "byte": 2, "label": "ground", "colour": "#6b6f76" }], where color is
read as well as colour. The map is ordered by class byte, since a JSON
object has no order worth trusting; a list is kept in the producer's own
order, because they chose it and it drives the default palette. Anything
that is neither shape throws naming both, and validateMeta reports the
same thing at bake time rather than leaving it to surface as a broken class
table at first render. A table longer than MAX_CLASSES is truncated with a
warning naming the dropped count, so a viewer handed a 20-class sidecar
still draws something.
The height ramp
setColourMode("height") ramps colour over local height, and by default the
ramp spans the cloud's whole vertical extent. That is right for a clean
cloud and wrong for a real one: a scene with a few below-datum returns and
one tall roof, -6.4 m to 82.5 m on a real street, squeezes everything a
viewer came to look at into a slice of the ramp.
cloud.setHeightRange(0, 25); // local metres, live
cloud.resetHeightRange(); // back to bboxLocal.y
Or set the band up front with the heightRange option, so the first frame
is already right. Bounds are sorted if they arrive the wrong way round and a
collapsed range is widened rather than dividing by zero in the shader; a
non-finite bound throws, and a call on a layer that has been removed from
the map warns and does nothing.
Fog scales with the scene
Fog distances are in metres from the eye, so one pair of constants cannot
serve both a 4 km aerial capture and a 340 m street. Both bounds default
from the cloud's own horizontal extent: nearM is 0.6 of the horizontal
diagonal, floored at 120 m, and farM is three times nearM.
createPointCloudLayer({ meta, raw }, {
fog: { colour: "#dce7f2", nearM: 300, farM: 900 },
});
Anything you pass wins, and the two default independently, so { colour }
on its own still gets extent-scaled distances. Set colour to your
basemap's fog colour, or the cloud fades towards a grey the map never
reaches. If you would rather choose the distances by hand:
| Scene | Extent | nearM | farM |
|---|---|---|---|
| One junction, a yard | under 150 m | 120 | 360 |
| A street, a short corridor | 150 to 600 m | 200 to 350 | 600 to 1050 |
| A district | 600 m to 2 km | 400 to 1200 | 1200 to 3600 |
| Aerial, a whole town | over 2 km | 1500+ | 4500+ |
Fog set too near for the scene is the failure worth knowing about: the cloud
flattens to even grey as soon as the camera pulls back, which presents as a
broken renderer rather than as fog. autoFogRange(meta.bboxLocal) is
exported if you want the derived pair as a starting point.
Terrain
Bake absolute heights into your payload. Every point is drawn exactly where the payload puts it (nothing in the SDK displaces a cloud by terrain) and MapLibre displaces its own 3D ground by absolute metres. A cloud on any other datum is therefore off by the height of the hill the moment terrain is switched on: the street either floats above it or is buried inside it.
DEM-subtracted payloads are not supported. No option makes one render correctly. If your producer subtracted a DEM, put the heights back before encoding: estimate the smooth residual between the survey's datum and the DEM and subtract that, rather than resampling ground, which keeps the survey's own camber, kerb faces and gradient instead of replacing them with a resampled surface.
The one terrain field in the sidecar moves the camera, not the points.
absoluteHeights: true (the default when the field is absent)
reconstructs the eye at an altitude that includes the terrain elevation at
the map centre, which is where it belongs for a cloud whose heights are
absolute. Set it to false only for a cloud on a flat z = 0 datum with
terrain off.
That sounds like a detail and is not. MapLibre gives a custom layer a combined matrix and no world-space camera, so the eye position has to be derived, and with terrain on, the camera orbits the terrain surface, not the z = 0 plane. Get it wrong in hilly country and every point size and every fog distance is computed from an eye a hundred metres underground. The cloud still renders; it just renders wrong, and nothing in the picture says why.
terrainRelative is the deprecated 0.1.0 spelling of absoluteHeights,
read as an alias with the same polarity (absoluteHeights wins if both are
set) so existing sidecars render identically. Its old documentation
promised that the layer would add terrain elevation back onto
DEM-subtracted points. It never did.
Streaming COPC
Added in 0.3.0. Absent from 0.1.0.
The payload path above is one buffer, fetched whole, and it has a ceiling at
a few million points. openCopc is the other path: a
COPC file read directly over HTTP range requests, its
own octree traversed each frame by a screen-space-error test, and only the
nodes this camera can resolve fetched, decompressed in a Web Worker pool and
drawn. One URL, no tiling server, no bake step, and the file may be
gigabytes. Our own Montreal scene streams 23.7 million points
out of a 233 MB file on somebody else's bucket.
npm install @mapmap/points three maplibre-gl laz-perf
laz-perf is an optional peer dependency, imported lazily inside the
worker on the first decode, so a page that never opens a COPC downloads no
wasm decoder.
import {
ASPRS_LAS14_CLASSES,
createPointCloudLayer,
openCopc,
} from "@mapmap/points";
// Two range requests and no points: the LAS public header with its VLR
// block, then the root hierarchy page. Under 64 KB, whatever the file size.
const source = await openCopc("https://example.org/city.copc.laz", {
source: "Ville de Montréal, Open Government Licence",
});
map.fitBounds(source.meta.bboxWgs!);
const cloud = createPointCloudLayer(source, { fog: { colour: "#dce7f2" } });
map.on("style.load", () => map.addLayer(cloud));
// Poll, do not push: the counters change every frame and no readout needs
// them at 60 Hz.
setInterval(() => {
const s = cloud.getStats()!;
ui.textContent = `${fmt(s.residentPoints)} of ${fmt(s.totalPoints)} points`;
}, 500);
residentPoints / totalPoints never reaches 1 and should not pretend to.
The honest label is "2.1M of 23.7M points", not a progress bar to 100 %: a
streamed cloud is a window onto something larger, not a download. The rest
of StreamStats is what a diagnostics panel wants, and busy is the flag
that says whether anything is still outstanding.
Everything the layer already does still applies. setColourMode,
setClassVisible, setPointSize, setHeightRange and getFps behave
identically on a streamed source, and getVisiblePointCount() is exact and
live rather than read off classCounts. Three methods are new and are the
quality controls: setChannel("rgb" | "intensity") re-decodes the resident
set with zero new range requests while the compressed cache is warm,
setTargetSse(px) moves the screen-space-error target, and
setPointBudget(points) moves the resident-point ceiling.
Classes are discovered, not declared, and the numbers are estimates.
COPC declares no class taxonomy and LAS 1.4 defines no statistics VLR, so an
ASPRS-numbered file and a vendor-numbered one are indistinguishable to a
reader. meta.classes and meta.classCounts are therefore absent on a
streamed source. Ask the points that have arrived instead:
const seen = cloud.observedClasses(); // [{ value: 2, count: 3469091 }, …]
cloud.setClasses(ASPRS_LAS14_CLASSES); // a uniform write, not a recompile
observedClasses() is a sample: it describes the nodes decoded so far,
not the file. It is a good sample from the first request, because a COPC
root node is a spatially uniform subsample of the whole cloud by
construction, and it sharpens as levels arrive. Label it as an estimate in
your UI, the way the demo viewer does; a count that moves as the camera
moves is honest only if it says so. ASPRS_LAS14_CLASSES is exported and
never applied by default: it is a curation of the 14 classes that occur
in national LiDAR, not the standard's 23, and picking it is a claim about
the file that only you can make.
The CRS comes from the file, never from its filename. The
LASF_Projection WKT record is read and reprojected by a self-contained
kit: geographic, Web Mercator, Transverse Mercator (every UTM zone, BNG,
MTM) and Lambert Conformal Conic 2SP, agreeing with pyproj to well under a
millimetre across a zone. Anything else throws UnsupportedCrsError, naming
the CRS, with the two ways forward. One of them is to bring your own:
import proj4 from "proj4";
const from = proj4("EPSG:2056");
openCopc(url, { crs: { toWgs84: (x, y) => from.inverse([x, y]) } });
No datum shift is performed, by design. A datum outside a known-safe list warns once, naming it and the likely offset, and renders anyway: a viewer that is a metre out is more useful than one that refuses.
Failure modes
The appeal of COPC is pointing a viewer at somebody else's URL, which is also where it breaks. A generic "failed to load" is treated as a bug here: each condition gets its own message, and the recoverable ones do not stop the scene.
| Condition | What happens |
|---|---|
No CORS headers, or Range rejected on the preflight | CopcTransportError with code: "network", listing the three headers the server must return and how to tell CORS from an unreachable host in the Network tab |
Server answers 200 to a ranged GET | code: "range-unsupported": it cannot be streamed at all. Serve it from storage that does ranges (S3, GCS, Azure Blob, any CDN, nginx as shipped) or bake it to a payload |
403, 404, 416 | code: "http-status", not retried. A signed URL or an Authorization header goes through requestInit |
| Retries spent on one byte range | code: "retries-exhausted" |
laz-perf not installed | code: "laz-perf-missing", naming the install command |
The caller's AbortSignal fires | code: "aborted", with name kept as AbortError. isAbortError(err) is true; this is never a failure |
| CRS unsupported, or no WKT record at all | UnsupportedCrsError, naming the CRS and the two ways forward |
| PDRF outside 6, 7, 8, or the COPC info VLR missing | Throws: it is a LAZ, not a COPC. Points at pdal translate |
| An unknown datum in the WKT | Warns once, naming it and the likely offset, and renders |
| One node fails after retries | Warns once for that node, cools down 30 s, carries on. The scene is coarse there rather than holed |
| WebGL context lost | Geometries are disposed and rebuilt from the decoded cache. Nothing is re-downloaded |
The first two are the ones that will actually happen. Both are the publisher's server, not your code, which is why the messages name headers rather than apologise.
Bundlers
openCopc builds its decode workers from
new URL("./worker.js", import.meta.url), which most bundlers understand.
Some, Next among them, copy the worker as an asset without processing
its module graph, and its lazy laz-perf import then cannot resolve. Two
options exist for exactly that, and you will want both together:
// your own worker module, so your bundler owns the graph
import { setLazPerfImporter } from "@mapmap/points/worker";
setLazPerfImporter(() => import("laz-perf/lib/worker/index.js"));
openCopc(url, {
createWorker: () =>
new Worker(new URL("./my-worker.ts", import.meta.url), { type: "module" }),
// Emscripten looks for its wasm beside its glue, and after bundling its
// glue is in a hashed chunk. Serve laz-perf/lib/worker/laz-perf.wasm and
// say where.
lazPerfWasmUrl: "/vendor/laz-perf/laz-perf.wasm",
});
Without them the failure is silent rather than loud: the decode promise never settles, nothing is logged, and the scene stays empty while the network and the georeferencing both work.
Clearance on route
Availability: published, in 0.5.0. The module is
points-sdk/src/clearance/: the types,formatClearance,formatLateralandreportToGeoJson, with their tests. Neither 0.3.0 nor the published 0.4.0 carried it, because the 0.4.0 on npm came off a feature branch that never reachedmainwhilemainheld the clearance module. 0.5.0 brings the two lines together, sonpm install @mapmap/pointsnow gets you this section along with everything else on this page. This site's own clearance viewer imports it from the package, which is how the code below is exercised. The copy this site used to sync verbatim from that source is gone, along with the script that generated it.
The gateway can answer whether a vehicle passes under what a survey actually
measured, rather than under what the map records
(POST /v1/clearance/along).
This module is the client half of that answer: the wire types, and two pure
functions for putting one in front of a person.
It does not fetch, it does not render, and it does not compute a clearance. The arithmetic lives once, server-side, so there is a single implementation of the safety-critical part rather than a second one drifting in a browser.
import { formatClearance, reportToGeoJson } from "@mapmap/points";
import type { RouteClearanceReport } from "@mapmap/points";
const report: RouteClearanceReport = await fetch(`${BASE}/v1/clearance/along`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
body: JSON.stringify({ geometry_polyline6: shape, vehicle_height_m: 4.2 }),
}).then((r) => r.json());
// The corridor, its coverage gaps and its measurement points, ready for
// map.addSource(). Pure: same report in, same features out.
map.getSource("clearance").setData(reportToGeoJson(report));
if (report.height.verdict === "fail") {
// "4.31 m measured (safe bound 4.12 m, surveyed Jan 2026)"
label.textContent = formatClearance(report.height.limiting.clearance);
}
Four rules are enforced by the type system rather than by this page, because a caveat in prose is a caveat somebody skips.
A pass cannot carry coverage gaps. Coverage is two distinct shapes
and the passing verdict admits only the complete one, which has no field for
gaps. A route that passes over ground nobody surveyed is a state the module
cannot represent. It is the typed version of the elevation API's rule that a
missing height is null and never a guess.
A measured clearance carries no signed or legal height. A survey measures
where a structure is; a sign states what you are permitted to do, with
somebody's margin already inside it. MeasuredClearance has no field for the
second, and a compile-time assertion keeps it that way. Signed heights appear
in TagComparison and nowhere else, where the verdict being reported is
about the tag.
A figure cannot be displayed without its bound. formatClearance takes
the whole measurement rather than a number, so you cannot format what you do
not hold, and it returns ClearanceText: a branded string a hand-built
"4.62 m" is not assignable to. Type a label as ClearanceText and it can
only be fed from here. Both figures round down to the centimetre, because
rounding a clearance up to make a label tidy is inventing headroom that was
never measured. Non-finite figures, negative bounds and a safe bound above
the measured value all throw rather than rendering as a confident label.
The two axes do not share a measurement type. A headroom metre and a
clear-width metre are different quantities, so MeasuredLateral has no
headroom field, MeasuredClearance has no clear width, and formatLateral
will not take the other's input. A lateral distance cannot be captioned as
overhead clearance by passing the wrong flag, because there is no flag.
reportToGeoJson(report) returns the corridor as a ClearanceGeoJson: the
route ribbon split by verdict, coverage gaps and advisory runs as their own
line features, and the limiting, contested and tightest points as point
features. Line features carry ClearanceLineProperties, points carry
ClearancePointProperties or LateralPointProperties, and every one of them
carries a ClearanceStatus to style on, so a map layer branches on a
property rather than re-deriving the verdict. The formatted text is already
in the properties, bound included.
Two groups of types here are contracts shipped ahead of the mechanism that
fills them, and are listed as such rather than left to look live.
TagAuditReport, TagComparison and OsmDraftEdit describe the comparison
between what the map claims and what a survey measured; no gateway route
serves one yet. EpochRegistration, ChangeReport, ChangeEvent and
ChangeKind describe change detection between survey epochs, which is the
same. Both are here so that a client written against them does not have to
be rewritten when they arrive.
Full export list, with every field, is in the API reference below. Everything the answer means, including why unsurveyed ground is never reported as clear, is on the clearance guide.
Limits
- The payload path is not an LOD system. One buffer, one draw call,
everything resident, payload fetched whole. Comfortable to a few million
points on a modern GPU; past that, bake to COPC and use
openCopc, or split the area into several clouds and add or remove layers by viewport. - One COPC per layer. Multi-file mosaics, and EPT sources, are not in 0.5.0.
- Vertical datums are the caller's problem. LAS
Zis used as-is;openCopc(url, { heightOffsetM })shifts the whole cloud if you know the offset. DEM-subtracted payloads remain unsupported. - Not a reprojection tool. The sidecar's
anchoris trusted as lng/lat. If you are unsure a dataset's declared CRS matches its coordinates, check it first. Thevalidate_geodataMCP tool andPOST /geodata/validateexist for exactly that. threeandmaplibre-glare peer dependencies. Provide a single shared copy of each: two MapLibre instances on one page break the WebGL context, and two copies of three give you two incompatible sets of classes.
API reference
Every public export of @mapmap/points, grouped by the job it does.
The layer. createPointCloudLayer(source, options?) returns a
PointCloudLayer: a MapLibre CustomLayerInterface with the extra methods
listed above (setColourMode, setClassVisible, setAllClassesVisible,
setPointSize, setHeightRange, resetHeightRange, getHeightRange,
getFps, getClasses, getVisiblePointCount, and a readonly meta).
PointCloudSourceis whatsourcemay be: aDecodedPointCloud, a{ meta, raw }pair which is decoded and validated for you, or, from 0.3.0, aStreamingSourcesuch as theCopcSourcethatopenCopcreturns. The three are interchangeable at the call site, which is what lets a viewer carry baked and streamed scenes down one code path.PointCloudLayerOptionsconfigures a layer:id(default"mapmap-point-cloud"),classes(aPointClass[]),colourMode(default"rgb"),pointSizeM(world size of one point, default0.15),maxPointPx(screen-size cap in device pixels, default10, raise it for offline video bakes),heightRange({ minM, maxM }, default the cloud's own vertical extent) andfog({ colour?, nearM?, farM? }, where each distance defaults on its own from the cloud's horizontal extent).ColourModeis"rgb" | "class" | "height": the payload's own colours, flat per-class colours, or a ramp over local height.getClasses()returns the layer's liveResolvedClasses, ornullfor a layer created without a class table. Itsvisiblearray is the one the shader reads, so it never goes stale behindsetClassVisibleorsetAllClassesVisible.getVisiblePointCount()returns how many points are currently drawn:meta.countwhen there is no class table, otherwise the sum ofmeta.classCountsover the visible classes, ornullwhen the sidecar carries noclassCounts.localOffset(anchor, lngLat)returns the{ x, z }offset in metres (x east, z south) of a lng/lat from a cloud's anchor, using MapLibre's Mercator maths.
Streaming a COPC (added in 0.3.0; absent from 0.1.0).
openCopc(source, options?)reads a COPC's LAS public header and root hierarchy page and fetches no points, returning aCopcSource.sourceis a URL, or aRangeGetterif you want to read the bytes yourself.CopcOpenOptionsis every knob on the read:anchor,crs(a{ toWgs84(x, y) }hook, whichproj4satisfies as-is),heightOffsetM,channel,workers,createWorker,lazPerfWasmUrl,cacheBytes,compressedCacheBytes,coalesceGapBytes,coalesceMaxBytes,requestInit(auth headers, credentials, a cache mode),getRangeandgetPageRange,sourceandsensor(whatmeta.sourceandmeta.sensorsay, which is where the credit goes),onWarnandsignal.CopcSourceis the opened file:kind: "copc",url,meta, thechannelsits PDRF allows and thechannelin the colour bytes now,setChannel(channel), astatsledger, the parsedheader,infoand projectiondescriptor, anddispose(). Calldispose(): a source owns a worker pool and its in-flight sockets, and a scene switch that walks away from one leaks both.CopcChannelis"rgb" | "intensity": which scalar lands in the three colour bytes. A channel is what is in the bytes and changing it re-decodes; a colour mode is how the shader paints them and changing it is a uniform write.RangeGetteris(begin, end, signal?) => Promise<Uint8Array>, withendexclusive. It is the whole I/O seam: pass one and nofetchof ours runs.CopcCloudMetais the metadata synthesised from the header and the COPC info VLR. It is aPointCloudMeta(soanchor,origin,quant,count,bboxLocal,bboxWgs,source,sensorall mean what they mean everywhere else) plusstreaming: true,totalPoints,maxLevel,spacingM,pdrf,hasRgb,hasNir, the file's ownwktand theprojectionfamily used to place it.classesandclassCountsare deliberately absent.quantis the root node's, descriptive only: every node carries its own, so code that multiplies by it is wrong for all the others.StreamCloudMetais the container-agnostic half of that (streaming,totalPoints,spacingM,maxLevel), andStreamingSourceis the container-agnostic sourcecreatePointCloudLayerconsumes. COPC is the only container in 0.5.0; the pair exists so a second one is not a second code path.isStreamingSource(source)is the type guard, for a viewer branching on which kind of cloud it was handed.getStats()returnsStreamStatsfor a streamed layer andnullfor a baked one:residentPoints,residentNodes,selectedNodes,totalPoints,pendingRequests,cacheBytes,cacheBudgetBytes,failedNodes,maxLevelResidentandbusy(true while anything is outstanding, which is the repaint condition).observedClasses()returnsObservedClass[], each{ value, count }: the class bytes present in the resident set right now, counted from points the workers decoded anyway. A sample, not a census.setClasses(table)names and colours them afterwards.CopcDataStats, onsource.stats, is the request ledger:requests,nodeRequests,pageRequests,bytes,wastedBytes(bytes inside a coalesced span nobody asked for),nodesDecoded,nodesFailed,cacheHits,decodedCacheBytesandcompressedCacheBytes. It is what a proof run measures and what tells you whether a channel switch really cost zero requests.ASPRS_LAS14_CLASSESis a ready-madePointClass[]for files that follow the standard,ASPRS_LAS14_CLASS_LABELSis the byte-to-label map behind it andasprsClassLabel(value)is the single lookup,undefinedfor a byte the curation does not name. Never applied by default, on either path.pointBudgetForDevice(deviceMemoryGb?, override?)andcacheBytesForBudget(pointBudget, override?)are the defaults behindpointBudgetandcacheBytes, anddeviceMemoryGb()readsnavigator.deviceMemorywithout assuming a DOM. It is Chromium-only, soundefinedtakes the conservative tier rather than the optimistic one. Together they are where a quality slider should start.
Streaming errors. Every one names what to do next, and all of them are
throwable from openCopc or reported through onWarn.
CopcTransportErrorcarries acode(CopcTransportErrorCode), plus thestatusandurlwhen there were any. The codes are"range-unsupported","network","http-status","retries-exhausted","aborted","laz-perf-missing"and"invalid-request". Branch on the code: CORS, a server that ignoresRangeand a plain 404 need three different fixes.isAbortError(err)is true for our"aborted"code and for the DOMAbortErrorandTimeoutError, so one check covers a cancelled load however it was cancelled. An abort is never a failure and is never retried.UnsupportedCrsErrorcarriescrsName,epsgandfamily. Its message names the CRS rather than saying "unsupported projection", because the name is what sends a developer to the two fixes under it: supply acrshook, or reproject the file.
The wire format (pure, no WebGL or MapLibre).
decodePointCloud(meta, raw)points two zero-copy typed-array views at a payload after checking its byte length, returning aDecodedPointCloud({ positions: Uint16Array, colours: Uint8Array, meta }). It throws on a truncated or mismatched payload rather than rendering a partial cloud.PointCloudMetais the JSON sidecar:anchor([lng, lat]),quant(metres per position unit),origin,count,bboxLocal(aLocalBox), optionalbboxWgs,classes(aMetaClassTable, either shape),classCounts,absoluteHeights(defaulttrue;terrainRelativeis its deprecated alias),sourceandsensor.absoluteHeightsFromMeta(meta)resolves that pair to the single boolean the layer uses, preferringabsoluteHeightsand warning once if a sidecar still spells itterrainRelative.validateMeta(meta)returns the problems with a sidecar, an empty array meaning usable, and covers the class table's shape and the provenance strings' types as well as the geometry.classTableProblems(classes)is the class-table half on its own, for a baker checking one table.LocalBoxis the local bounding box in metres:{ x, y, z }, each a[min, max]pair, in the cloud's east/up/south frame.BYTES_PER_POINTis10(3 x Uint16 positions + 4 x Uint8 colour), andpayloadBytes(count)returns the byte length a payload ofcountpoints must have.
Classes.
PointClassis one row of a class table:{ value, label, colour?, visible? }, wherevalueis the class byte andcolouris#rrggbbor an[r, g, b]triple in 0 to 1.resolveClasses(classes)normalises aPointClass[]into the flat arrays the shader wants, returned asResolvedClasses({ values, labels, colours, visible, lookup }). It throws on duplicate bytes, a byte outside 0 to 255, or a table overMAX_CLASSES.classesFromMeta(meta.classes)builds aPointClass[]from either sidecar shape,MetaClassTable: aRecord<string, string>of class byte to label, or aMetaClassEntry[]of{ byte, label, colour? }(withcolorread too). It returns an empty table for an absent one, truncates pastMAX_CLASSESwith a warning, and throws on any third shape.visiblePointCount(classes, meta.classCounts)is the pure helper behindgetVisiblePointCount(), for code holding aResolvedClassesof its own. A visible class the producer never counted contributes nothing, so a partialclassCountsunder-reports rather than throwing.DEFAULT_PALETTEis the built-in, colour-blind-aware palette used when a class declares no explicit colour; its early entries stay separable under common colour-vision deficiencies.MAX_CLASSESis16: a bounded uniform scan with a compile-time trip count, which the shader is generated against. It is a product decision rather than a WebGL-version artefact, so MapLibre 6 and WebGL 2 do not widen it; a wider producer taxonomy needs a texture lookup and a different shader.
Scene ranges (pure, the defaults the layer applies).
autoFogRange(bboxLocal)returns theFogRange({ nearM, farM }) a cloud gets whenfog.nearMorfog.farMis unset:MIN_FOG_NEAR_M(120) orFOG_NEAR_DIAGONAL_FRACTION(0.6) of the horizontal diagonal, whichever is larger, andFOG_FAR_MULTIPLE(3) times that.defaultHeightRange(bboxLocal)returns theMetreRange({ minM, maxM }) the height ramp spans unsteered, widened toMIN_HEIGHT_SPAN_M(8) for a flat cloud, andclampHeightRange(minM, maxM)is whatsetHeightRangeputs a caller's bounds through: sorted, a collapsed range widened, a non-finite one thrown.
Camera reconstruction (used internally by the layer, exported for custom renderers).
CameraViewis the subset of a MapLibre map the maths reads:centre,zoom,bearing,pitch,fovDeg?,bufferHeightPx(device pixels, not CSS) andcentreElevationM?.eyePosition(view, centre)returns theEyePosition({ x, y, z, distanceM }) in the cloud's local frame, raising the eye by the terrain height at the centre when terrain is on.LocalCentreis where the map centre sits in the local frame ({ x, z }).stickyElevation(sampled, remembered)carries the last elevation the DEM actually answered with across the frames wherequeryTerrainElevationreturnsnull(before a tile has loaded), so the eye never drops to sea level and pops back up when it arrives.nullonly until the first real sample.metresPerPixel(lat, zoom)returns the ground metres one screen pixel covers, andpointSizeScale(worldSizeM, bufferHeightPx, fovDeg?)returns the device pixels a point of that world size subtends at one metre (the shader divides by distance so points shrink with depth).DEFAULT_FOV_DEG(36.87) is MapLibre's default vertical field of view, the fallback whenCameraView.fovDegis unset.
Clearance on route (types and pure functions; nothing here computes a clearance, and nothing here fetches or renders).
RouteClearanceReportis the answer for one route and one vehicle:basis, aClearanceEnforcementblock, the route, theVehicleQuery, aRouteVerdictfor height, aRouteVerdictornot_assessedfor width, the datasets consulted, advisories and anexplanation.RouteVerdictis the height answer:pass,fail,indeterminateorno_verdict. Apassaccepts only completeCoverage, so a route that passes over unsurveyed ground is a state the type cannot represent. AnindeterminatecarriesIndeterminateReasonvalues beside its sentence.WidthVerdictis the width answer, and a separate type carrying separate measurements.Coverageiscomplete(no field for gaps) orpartial(withnot_surveyedandinsufficient_dataruns, each aRouteSection).MeasuredClearanceis one overhead measurement and its bound:headroom_m,sigma_m,sampling_gap_m,safe_headroom_m, anOverheadClass,support_m2andsurveyed_on. It has no field for a signed or legal height.LimitingPointplaces one on the route with a viewer deep link.MeasuredLateralis one lateral measurement:clear_width_m,sigma_m,sampling_gap_m,safe_clear_width_m, anObstructionClassinlimited_by,support_m2andsurveyed_on. It has no headroom field, so a clear width cannot be captioned as overhead clearance.LateralPointplaces one on the route.formatClearance(clearance)andformatLateral(lateral)render a measurement as text, bound included, and returnClearanceText, a branded string a hand-built label cannot be substituted for. Each takes only its own measurement type.reportToGeoJson(report)returns aClearanceGeoJson: the route ribbon, coverage gaps, advisory runs and measurement points, asClearanceFeaturevalues carryingClearanceLineProperties,ClearancePointPropertiesorLateralPointProperties, and aClearanceStatusfor styling.TagAuditReportcompares what OpenStreetMap claims against what the survey measured:TagComparisonrecords andOsmDraftEditdrafts.ClearanceFieldSidecaris the baked artefact's sidecar, including its mandatoryDatumBlockandVerticalBasis.EpochRegistration,ChangeReport,ChangeEventandChangeKindare the change-detection contracts, shipped ahead of the mechanism.
Core (WASM): @mapmap/core
Availability: live on npm.
Node only, today. npm serves 0.3.0, which is the wasm-pack
nodejsbuild and cannot load in a browser. 0.4.0 adds a browser build alongside it; it is not published yet, so do not pin^0.4.0.
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.2.
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
// cancelTerritoryInstall("uk") stops it: a "cancelled" territoryProgress
// event, an E_CANCELLED rejection, nothing partial left on disk.
const { routes } = await nav.computeRoute({
profile: "car",
origin: { latitude: 51.5074, longitude: -0.1278 },
destination: { latitude: 51.5081, longitude: -0.0759 },
});
Two conditions on that happy path, one per platform. Both fail closed with a named error code rather than degrading, so read them before you build.
Android: the factory verifying key, and first install
android/build.gradle needs the territory channel's ed25519 verifying key
baked in at build time, via the mapmap.factoryPubkeyHex Gradle property or
the MAPMAP_FACTORY_PUBKEY_HEX environment variable. Without it init()
rejects with E_INIT_FAILED, deliberately, rather than failing later
inside the core.
# android/gradle.properties, or -Pmapmap.factoryPubkeyHex=… on the build
mapmap.factoryPubkeyHex=<the channel's ed25519 verifying key, hex>
Ask us for the key for the channel you are pointed at; it is the anchor the verify-then-promote install checks every package against, so it is not a value to guess or copy between channels.
On ai.mapmap:core 0.3.0, installTerritory(id) for a territory that is
not yet installed rejects with E_INTERNAL. The core installs a
first territory only from a local .snpkg or package directory
(installSnpkg / installDir), and the hosted channel serves an index,
manifests and layer blobs but no package archive yet. Updating an
already-installed territory works end to end, signed differential update
included. Ship the first .snpkg with the app or side-load it until the
channel serves archives.
iOS: on-device routing needs MapMapValhalla
computeRoute runs Valhalla on the device through the iOS SDK's
MapMapValhalla product, which ships only via SwiftPM; there is no binary
pod for it yet. Add the package to your app target (File, Add Package
Dependencies…, https://github.com/Mapmapai/mapmap-ios) and link
MapMapValhalla. Without it the bridge still builds and everything else
works, but computeRoute rejects with E_INTERNAL rather than
pretending. The CocoaPods dependency MapMapKit (~> 0.6.0) covers the
rest of the SDK; until it is on the CDN, pin it in your Podfile:
pod 'MapMapKit', podspec: 'https://raw.githubusercontent.com/Mapmapai/mapmap-ios/main/MapMapKit.podspec'
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.
API reference
The full JS surface of @mapmap/react-native.
Getting a handle.
getMapmapNav(useMock, mockOptions?)returns aMapmapNavHandle: the real native module whenuseMockisfalse, the pure-TypeScript mock whentrue.MapmapNavHandleisMapmapNavModule(the promise-based method surface) intersected with the typed event emitter.createMapmapNavMock(options?)builds aMapmapNavMockdirectly, for tests and UI work that want the simulator without going throughgetMapmapNav.MapmapNavMockimplements the wholeMapmapNavModulecontract in TypeScript with zero native dependency: phasedinstallTerritoryprogress, a plausible multi-routecomputeRoute, a guidance loop that emits at roughly 1 Hz, and a voice pulse on each banner change.MapmapNavMockOptionstunes the simulator:tickIntervalMs(guidance tick, default1000),territoryInstallDurationMs(default3000),simulateReroute(defaulttrue), androuteProvider, an optional callback that returns real routes so demo guidance follows real roads and only falls back to the synthetic dogleg when it returnsnull.NativeMapmapNavModuleis the type of the real native-backed handle (MapmapNavModuleplus the emitter surface). The native module is resolved lazily on first use, so importing the package never requires it.
Events. Each helper attaches a typed listener and returns an
EventSubscription (from expo-modules-core; call .remove() to detach).
They work identically against the real handle and the mock, because both
satisfy MapmapNavEmitterLike.
addGuidanceListener(handle, listener)for theguidancestream.addTerritoryProgressListener(handle, listener)forTerritoryProgressEvents during a download.addVoiceStateListener(handle, listener)forVoiceStateEvents ({ muted, volume, speaking }).addBridgeErrorListener(handle, listener)for aBridgeErrorraised outside a rejected promise (for example inside the guidance loop).MapmapNavEmitterLike<Events>is the structural emitter shape both handles implement (addListener, optionalremoveAllListeners), andMapmapNavEventsis the event-name to payload map (territoryProgress,guidance,voiceState,bridgeError).
Errors and payloads.
BridgeErroris{ code: BridgeErrorCode, message }, carried on every rejected promise.BridgeErrorCodeis the stable machine-readable set:E_NOT_INITIALISED,E_INIT_FAILED,E_NETWORK,E_AUTH,E_TERRITORY_ALLOWANCE,E_TERRITORY_VERIFY,E_TERRITORY_NOT_FOUND,E_NO_TERRITORY,E_NO_ROUTE,E_GUIDANCE_ACTIVE,E_NO_GUIDANCE,E_REPLAY_CORPUSandE_INTERNAL.TerritoryPhaseis"downloading" | "verifying" | "activating" | "done" | "failed", andTerritoryProgressEventcarries{ territoryId, phase, bytesDone, bytesTotal, error? }.VoiceStateEventis{ muted, volume, speaking }.LocationSource(part ofGuidanceOptions) is either{ kind: "live" }or{ kind: "replay", corpusPath, speedMultiplier? }, so guidance can be driven from live GPS or from a recorded.drive.jsonlcorpus for desk demos.
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, source distribution. Unlike
ai.mapmap:core, these two modules are not published to Maven Central or GitHub Packages. Build them from the source checkout (publishToMavenLocalor a composite build) until public artefacts land withsdk-v0.7.0, after head-unit hardware validation. See CarPlay & Android Auto for the install steps, 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, source distribution.
MapMapCarPlayis not a product of the publicMapmapai/mapmap-iospackage, so asking the 0.6.0 release for it will not resolve. Untilsdk-v0.7.0, reference theMapMapKitpackage from the SDK source checkout as a local SwiftPM path dependency. Public artefacts follow head-unit hardware validation. See CarPlay & Android Auto for the install steps, 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