Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / sdks · raw .md

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

SDKPackageVersionPlatform floorPeer requirements
Web maps@mapmap/maps0.12.0Node ≥ 18 to build; ESM onlymaplibre-gl ≥ 5 < 7, pmtiles ≥ 3 < 5
Point clouds@mapmap/points0.5.0Node ≥ 18 to build; ESM onlymaplibre-gl ≥ 5.6 < 6 or ≥ 6.9 < 7, three ≥ 0.160 < 1, laz-perf ≥ 0.0.7 optional (COPC only)
Core (WASM)@mapmap/core0.3.0Node (the npm artefact is the Node build)none
React Native@mapmap/react-native0.3.2iOS 16.4, minSdk 26; Expo SDK 52+, or bare React Native 0.74+expo-modules-core, react, react-native; expo ≥ 52 optional
Androidai.mapmap:core0.3.0minSdk 26, JDK 17 to buildnone
iOSMapMapKit (Mapmapai/mapmap-ios)0.6.0iOS 16.4, Swift tools 5.9none

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.

bash
npm install @mapmap/maps maplibre-gl pmtiles

The map needs MapLibre's stylesheet and a container with a real height:

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

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

ts
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 });
Multi-stop routing drawn live with RouteLayer · © OpenStreetMap contributors © OpenMapTiles

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.

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

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

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:

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

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

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

ts
import { createMap, RouteLayer, GuidanceBanner, PositionPuck } from "@mapmap/maps";
import theme from "./midnight-fleet.theme.json"; // downloaded from Studio

const map = createMap({ container: "map", apiKey: "snk_...", style: theme });
await map.whenReady();

const routes = new RouteLayer(map);   // 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.

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

ts
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/points gets 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: new openCopc and 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 reached main, while main separately 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, and three moving 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.

bash
npm install @mapmap/points three maplibre-gl
ts
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:

arduino
[ 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:

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

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

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

SceneExtentnearMfarM
One junction, a yardunder 150 m120360
A street, a short corridor150 to 600 m200 to 350600 to 1050
A district600 m to 2 km400 to 12001200 to 3600
Aerial, a whole townover 2 km1500+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.

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

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

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

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

ConditionWhat happens
No CORS headers, or Range rejected on the preflightCopcTransportError 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 GETcode: "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, 416code: "http-status", not retried. A signed URL or an Authorization header goes through requestInit
Retries spent on one byte rangecode: "retries-exhausted"
laz-perf not installedcode: "laz-perf-missing", naming the install command
The caller's AbortSignal firescode: "aborted", with name kept as AbortError. isAbortError(err) is true; this is never a failure
CRS unsupported, or no WKT record at allUnsupportedCrsError, naming the CRS and the two ways forward
PDRF outside 6, 7, 8, or the COPC info VLR missingThrows: it is a LAZ, not a COPC. Points at pdal translate
An unknown datum in the WKTWarns once, naming it and the likely offset, and renders
One node fails after retriesWarns once for that node, cools down 30 s, carries on. The scene is coarse there rather than holed
WebGL context lostGeometries 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:

ts
// your own worker module, so your bundler owns the graph
import { setLazPerfImporter } from "@mapmap/points/worker";
setLazPerfImporter(() => import("laz-perf/lib/worker/index.js"));
ts
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, formatLateral and reportToGeoJson, 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 reached main while main held the clearance module. 0.5.0 brings the two lines together, so npm install @mapmap/points now 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.

ts
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 Z is 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 anchor is trusted as lng/lat. If you are unsure a dataset's declared CRS matches its coordinates, check it first. The validate_geodata MCP tool and POST /geodata/validate exist for exactly that.
  • three and maplibre-gl are 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).

  • PointCloudSource is what source may be: a DecodedPointCloud, a { meta, raw } pair which is decoded and validated for you, or, from 0.3.0, a StreamingSource such as the CopcSource that openCopc returns. The three are interchangeable at the call site, which is what lets a viewer carry baked and streamed scenes down one code path.
  • PointCloudLayerOptions configures a layer: id (default "mapmap-point-cloud"), classes (a PointClass[]), colourMode (default "rgb"), pointSizeM (world size of one point, default 0.15), maxPointPx (screen-size cap in device pixels, default 10, raise it for offline video bakes), heightRange ({ minM, maxM }, default the cloud's own vertical extent) and fog ({ colour?, nearM?, farM? }, where each distance defaults on its own from the cloud's horizontal extent).
  • ColourMode is "rgb" | "class" | "height": the payload's own colours, flat per-class colours, or a ramp over local height.
  • getClasses() returns the layer's live ResolvedClasses, or null for a layer created without a class table. Its visible array is the one the shader reads, so it never goes stale behind setClassVisible or setAllClassesVisible.
  • getVisiblePointCount() returns how many points are currently drawn: meta.count when there is no class table, otherwise the sum of meta.classCounts over the visible classes, or null when the sidecar carries no classCounts.
  • 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 a CopcSource. source is a URL, or a RangeGetter if you want to read the bytes yourself.
  • CopcOpenOptions is every knob on the read: anchor, crs (a { toWgs84(x, y) } hook, which proj4 satisfies as-is), heightOffsetM, channel, workers, createWorker, lazPerfWasmUrl, cacheBytes, compressedCacheBytes, coalesceGapBytes, coalesceMaxBytes, requestInit (auth headers, credentials, a cache mode), getRange and getPageRange, source and sensor (what meta.source and meta.sensor say, which is where the credit goes), onWarn and signal.
  • CopcSource is the opened file: kind: "copc", url, meta, the channels its PDRF allows and the channel in the colour bytes now, setChannel(channel), a stats ledger, the parsed header, info and projection descriptor, and dispose(). Call dispose(): a source owns a worker pool and its in-flight sockets, and a scene switch that walks away from one leaks both.
  • CopcChannel is "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.
  • RangeGetter is (begin, end, signal?) => Promise<Uint8Array>, with end exclusive. It is the whole I/O seam: pass one and no fetch of ours runs.
  • CopcCloudMeta is the metadata synthesised from the header and the COPC info VLR. It is a PointCloudMeta (so anchor, origin, quant, count, bboxLocal, bboxWgs, source, sensor all mean what they mean everywhere else) plus streaming: true, totalPoints, maxLevel, spacingM, pdrf, hasRgb, hasNir, the file's own wkt and the projection family used to place it. classes and classCounts are deliberately absent. quant is the root node's, descriptive only: every node carries its own, so code that multiplies by it is wrong for all the others.
  • StreamCloudMeta is the container-agnostic half of that (streaming, totalPoints, spacingM, maxLevel), and StreamingSource is the container-agnostic source createPointCloudLayer consumes. 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() returns StreamStats for a streamed layer and null for a baked one: residentPoints, residentNodes, selectedNodes, totalPoints, pendingRequests, cacheBytes, cacheBudgetBytes, failedNodes, maxLevelResident and busy (true while anything is outstanding, which is the repaint condition).
  • observedClasses() returns ObservedClass[], 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, on source.stats, is the request ledger: requests, nodeRequests, pageRequests, bytes, wastedBytes (bytes inside a coalesced span nobody asked for), nodesDecoded, nodesFailed, cacheHits, decodedCacheBytes and compressedCacheBytes. It is what a proof run measures and what tells you whether a channel switch really cost zero requests.
  • ASPRS_LAS14_CLASSES is a ready-made PointClass[] for files that follow the standard, ASPRS_LAS14_CLASS_LABELS is the byte-to-label map behind it and asprsClassLabel(value) is the single lookup, undefined for a byte the curation does not name. Never applied by default, on either path.
  • pointBudgetForDevice(deviceMemoryGb?, override?) and cacheBytesForBudget(pointBudget, override?) are the defaults behind pointBudget and cacheBytes, and deviceMemoryGb() reads navigator.deviceMemory without assuming a DOM. It is Chromium-only, so undefined takes 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.

  • CopcTransportError carries a code (CopcTransportErrorCode), plus the status and url when 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 ignores Range and a plain 404 need three different fixes.
  • isAbortError(err) is true for our "aborted" code and for the DOM AbortError and TimeoutError, so one check covers a cancelled load however it was cancelled. An abort is never a failure and is never retried.
  • UnsupportedCrsError carries crsName, epsg and family. 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 a crs hook, 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 a DecodedPointCloud ({ positions: Uint16Array, colours: Uint8Array, meta }). It throws on a truncated or mismatched payload rather than rendering a partial cloud.
  • PointCloudMeta is the JSON sidecar: anchor ([lng, lat]), quant (metres per position unit), origin, count, bboxLocal (a LocalBox), optional bboxWgs, classes (a MetaClassTable, either shape), classCounts, absoluteHeights (default true; terrainRelative is its deprecated alias), source and sensor.
  • absoluteHeightsFromMeta(meta) resolves that pair to the single boolean the layer uses, preferring absoluteHeights and warning once if a sidecar still spells it terrainRelative.
  • 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.
  • LocalBox is the local bounding box in metres: { x, y, z }, each a [min, max] pair, in the cloud's east/up/south frame.
  • BYTES_PER_POINT is 10 (3 x Uint16 positions + 4 x Uint8 colour), and payloadBytes(count) returns the byte length a payload of count points must have.

Classes.

  • PointClass is one row of a class table: { value, label, colour?, visible? }, where value is the class byte and colour is #rrggbb or an [r, g, b] triple in 0 to 1.
  • resolveClasses(classes) normalises a PointClass[] into the flat arrays the shader wants, returned as ResolvedClasses ({ values, labels, colours, visible, lookup }). It throws on duplicate bytes, a byte outside 0 to 255, or a table over MAX_CLASSES.
  • classesFromMeta(meta.classes) builds a PointClass[] from either sidecar shape, MetaClassTable: a Record<string, string> of class byte to label, or a MetaClassEntry[] of { byte, label, colour? } (with color read too). It returns an empty table for an absent one, truncates past MAX_CLASSES with a warning, and throws on any third shape.
  • visiblePointCount(classes, meta.classCounts) is the pure helper behind getVisiblePointCount(), for code holding a ResolvedClasses of its own. A visible class the producer never counted contributes nothing, so a partial classCounts under-reports rather than throwing.
  • DEFAULT_PALETTE is 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_CLASSES is 16: 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 the FogRange ({ nearM, farM }) a cloud gets when fog.nearM or fog.farM is unset: MIN_FOG_NEAR_M (120) or FOG_NEAR_DIAGONAL_FRACTION (0.6) of the horizontal diagonal, whichever is larger, and FOG_FAR_MULTIPLE (3) times that.
  • defaultHeightRange(bboxLocal) returns the MetreRange ({ minM, maxM }) the height ramp spans unsteered, widened to MIN_HEIGHT_SPAN_M (8) for a flat cloud, and clampHeightRange(minM, maxM) is what setHeightRange puts 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).

  • CameraView is the subset of a MapLibre map the maths reads: centre, zoom, bearing, pitch, fovDeg?, bufferHeightPx (device pixels, not CSS) and centreElevationM?.
  • eyePosition(view, centre) returns the EyePosition ({ x, y, z, distanceM }) in the cloud's local frame, raising the eye by the terrain height at the centre when terrain is on.
  • LocalCentre is 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 where queryTerrainElevation returns null (before a tile has loaded), so the eye never drops to sea level and pops back up when it arrives. null only until the first real sample.
  • metresPerPixel(lat, zoom) returns the ground metres one screen pixel covers, and pointSizeScale(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 when CameraView.fovDeg is unset.

Clearance on route (types and pure functions; nothing here computes a clearance, and nothing here fetches or renders).

  • RouteClearanceReport is the answer for one route and one vehicle: basis, a ClearanceEnforcement block, the route, the VehicleQuery, a RouteVerdict for height, a RouteVerdict or not_assessed for width, the datasets consulted, advisories and an explanation.
  • RouteVerdict is the height answer: pass, fail, indeterminate or no_verdict. A pass accepts only complete Coverage, so a route that passes over unsurveyed ground is a state the type cannot represent. An indeterminate carries IndeterminateReason values beside its sentence.
  • WidthVerdict is the width answer, and a separate type carrying separate measurements.
  • Coverage is complete (no field for gaps) or partial (with not_surveyed and insufficient_data runs, each a RouteSection).
  • MeasuredClearance is one overhead measurement and its bound: headroom_m, sigma_m, sampling_gap_m, safe_headroom_m, an OverheadClass, support_m2 and surveyed_on. It has no field for a signed or legal height. LimitingPoint places one on the route with a viewer deep link.
  • MeasuredLateral is one lateral measurement: clear_width_m, sigma_m, sampling_gap_m, safe_clear_width_m, an ObstructionClass in limited_by, support_m2 and surveyed_on. It has no headroom field, so a clear width cannot be captioned as overhead clearance. LateralPoint places one on the route.
  • formatClearance(clearance) and formatLateral(lateral) render a measurement as text, bound included, and return ClearanceText, a branded string a hand-built label cannot be substituted for. Each takes only its own measurement type.
  • reportToGeoJson(report) returns a ClearanceGeoJson: the route ribbon, coverage gaps, advisory runs and measurement points, as ClearanceFeature values carrying ClearanceLineProperties, ClearancePointProperties or LateralPointProperties, and a ClearanceStatus for styling.
  • TagAuditReport compares what OpenStreetMap claims against what the survey measured: TagComparison records and OsmDraftEdit drafts.
  • ClearanceFieldSidecar is the baked artefact's sidecar, including its mandatory DatumBlock and VerticalBasis.
  • EpochRegistration, ChangeReport, ChangeEvent and ChangeKind are 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 nodejs build 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.

bash
npm install @mapmap/core
js
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.

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

jsonc
{
  "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-properties line above is not optional.

Not available in Expo Go. The native module needs a dev client. Pass true to getMapmapNav for 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.

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

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

ruby
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 a MapmapNavHandle: the real native module when useMock is false, the pure-TypeScript mock when true. MapmapNavHandle is MapmapNavModule (the promise-based method surface) intersected with the typed event emitter.
  • createMapmapNavMock(options?) builds a MapmapNavMock directly, for tests and UI work that want the simulator without going through getMapmapNav. MapmapNavMock implements the whole MapmapNavModule contract in TypeScript with zero native dependency: phased installTerritory progress, a plausible multi-route computeRoute, a guidance loop that emits at roughly 1 Hz, and a voice pulse on each banner change.
  • MapmapNavMockOptions tunes the simulator: tickIntervalMs (guidance tick, default 1000), territoryInstallDurationMs (default 3000), simulateReroute (default true), and routeProvider, an optional callback that returns real routes so demo guidance follows real roads and only falls back to the synthetic dogleg when it returns null.
  • NativeMapmapNavModule is the type of the real native-backed handle (MapmapNavModule plus 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 the guidance stream.
  • addTerritoryProgressListener(handle, listener) for TerritoryProgressEvents during a download.
  • addVoiceStateListener(handle, listener) for VoiceStateEvents ({ muted, volume, speaking }).
  • addBridgeErrorListener(handle, listener) for a BridgeError raised outside a rejected promise (for example inside the guidance loop).
  • MapmapNavEmitterLike<Events> is the structural emitter shape both handles implement (addListener, optional removeAllListeners), and MapmapNavEvents is the event-name to payload map (territoryProgress, guidance, voiceState, bridgeError).

Errors and payloads.

  • BridgeError is { code: BridgeErrorCode, message }, carried on every rejected promise. BridgeErrorCode is 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_CORPUS and E_INTERNAL.
  • TerritoryPhase is "downloading" | "verifying" | "activating" | "done" | "failed", and TerritoryProgressEvent carries { territoryId, phase, bytesDone, bytesTotal, error? }.
  • VoiceStateEvent is { muted, volume, speaking }.
  • LocationSource (part of GuidanceOptions) is either { kind: "live" } or { kind: "replay", corpusPath, speedMultiplier? }, so guidance can be driven from live GPS or from a recorded .drive.jsonl corpus for desk demos.

Android (Kotlin)

Availability: ai.mapmap:core 0.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 a minSdkVersion bump in your app (an app.json / expo-build-properties change 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.

kotlin
// 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")
    //   }
    // }
  }
}
kotlin
// 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):

kotlin
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 (publishToMavenLocal or a composite build) until public artefacts land with sdk-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; pin from: "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+).

swift
// 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
swift
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. MapMapCarPlay is not a product of the public Mapmapai/mapmap-ios package, so asking the 0.6.0 release for it will not resolve. Until sdk-v0.7.0, reference the MapMapKit package 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:

sh
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