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

news / offline-navigation · raw .md
SDK7 min readMapMap engineering
A route line continuing through a dense hatched no-connection zone, with every surrounding signal arc and network link severed at its boundary.

Offline turn-by-turn navigation, ADR rules included

A tanker carrying dangerous goods loses mobile signal the moment it enters a tunnel, and that tunnel is exactly where its ADR code decides whether it may legally be there. MapMap's offline turn-by-turn puts the whole chain on the device: an on-device Valhalla engine routing against a signed territory package, dimensional limits and ADR tunnel codes merged into the routing costing rather than filtered out afterwards, and a debounced off-route controller that recalculates locally when a driver misses a turn. No part of that path calls a network.

What actually runs on the device

Everything the route depends on. There is no "offline mode" that quietly degrades to a straight line and a cached tile.

PieceWhat it doesWhere it comes from
Territory packageRouting tiles, base-map tiles, a geocode index and ADR restriction overlaysBuilt by our map factory (snfactory), ed25519-signed, installed on disk
Routing engineroute(request_json) -> response_jsonvalhalla-mobile (MIT), injected by the platform
Navigation core (sn-nav-core)Request building, ADR merge, guidance state machine, off-route debounceFirst-party Rust, shared by Android, iOS and WASM

The navigation core performs no I/O at all. The on-device engine reaches it through a LocalRouter callback interface that Kotlin and Swift implement with their valhalla-mobile binding, which is what keeps the same crate compiling to WebAssembly. The territory package is the part that makes offline ADR possible: the restriction overlays are baked in at build time, so the device never asks anyone at run time what category a tunnel is. How those packages are signed, mirrored and updated is covered in territory packages and OTA updates and the territories documentation.

ADR enforcement offline is the hard part, and it is the point

Restrictions are merged into costing_options.truck before the request reaches the engine, so the route that comes back is one the vehicle can legally drive. This is the same merge the hosted gateway performs, running from the same crate.

@mapmap/core is on npm and needs no API key for the offline builders, so this runs as-is:

js
const { buildOfflineRouteRequest, checkTunnel } = require("@mapmap/core");

const body = buildOfflineRouteRequest(
  [{ lat: 51.4467, lon: 0.2553 }, { lat: 52.4862, lon: -1.8904 }],
  "truck",
  { heightM: 4.0, grossWeightT: 40, hazmat: true, tunnelCode: "C" },
);
// body is a JSON string, ready to POST as-is. Parse it if you want the object.

checkTunnel({ hazmat: true, tunnelCode: "C" }, "D");

buildOfflineRouteRequest returns a JSON string, not an object, because the thing you do with it is hand it to a router verbatim. It is the request body any Valhalla endpoint accepts, on device or hosted. Parsed, it reads:

json
{
  "locations": [
    { "lat": 51.4467, "lon": 0.2553 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "truck",
  "costing_options": {
    "truck": {
      "adr_tunnel_code": "C",
      "hazmat": true,
      "height": 4,
      "length": 16.5,
      "weight": 40,
      "width": 2.55
    }
  }
}

checkTunnel answers the ADR 8.6.4 question locally, with its reasoning:

json
{
  "status": "blocked",
  "reason": "ADR 8.6.4: tunnel restriction code C forbids passage through tunnels of category D (worst-case reading; conditional clauses assumed to apply)"
}

Three details in there are deliberate. Dimensions you do not supply default to the EU maximum authorised dimensions under Council Directive 96/53/EC (4 m high, 2.55 m wide, 16.5 m long, 40 t), so an under-specified profile errs towards the largest legal vehicle rather than the smallest. A hazmat: true profile with no declared tunnel code is treated by checkTunnel as code B, the most restrictive non-quantity code, so it clears category A and nothing else, because "dangerous goods, code unknown" must not resolve to "no restriction". That is a compliance verdict, not a routing parameter: the request builder emits hazmat: true and no adr_tunnel_code for such a profile, because there is no declared code to send. And an explicit costing_options.truck field that disagrees with the ADR profile is rejected as a conflict rather than silently overridden: a wrong height or hazmat flag is a safety defect, not a preference to be reconciled.

Why a missed turn must not reroute on the first bad fix

Because a single GNSS fix flung off the carriageway by multipath recovers on the very next update, and rerouting on it wastes the on-device router and churns the driver's guidance. RerouteController is the debounce state machine between the guidance stream and the router, and it confirms a sustained departure against two gates that must both pass.

SettingDefaultWhat it gates
min_consecutive_offroute_fixes3Consecutive off-route fixes before a reroute can be confirmed
min_offroute_duration_s5.0Seconds continuously off route before a reroute can be confirmed
min_interval_between_reroutes_s10.0Cooldown after a reroute fires; reports CoolingDown instead of firing again
route_deviation_threshold_m25.0Metres from the route line before guidance calls it off route at all
minimum_horizontal_accuracy_m25Fixes worse than this can neither advance a step nor flag a deviation

Both gates, not either. A burst of fixes arriving in a fraction of a second meets the count but must not out-vote the duration requirement; a slow trickle of fixes meets the duration but must not out-vote the count. A Navigating update that carries an off-step deviation, where the driver is off the current step but still on the route polyline, resets the run rather than building towards a reroute, because off-step is not off-route.

The committed replay corpus is where this gets checked rather than asserted. The urban-canyon golden records a deviation of 50.73 m at t=34s and a return to the route at t=47s. That 13-second excursion is a real departure and clears both gates comfortably. A one-fix blip never reaches either.

An offline reroute steers you onward, not back to the turn you missed

The reroute anchor carries a Valhalla heading taken from the current course plus a heading_tolerance of 45°, deliberately tighter than Valhalla's own 60° default, so the router prefers edges aligned with the direction of travel. A reroute wants a firmer forward bias than a fresh route does, because the alternative is telling a 40-tonne truck to make an immediate U-turn back to the manoeuvre it just missed.

The controller also carries the costing model and options over from the originating request, because a Valhalla response never echoes what it was routed with. build_reroute_request produces:

json
{
  "locations": [
    { "lat": 51.5, "lon": -0.12, "heading": 90.0, "heading_tolerance": 45.0 },
    { "lat": 51.51, "lon": -0.11, "type": "via" },
    { "lat": 51.52, "lon": -0.1, "type": "break" }
  ],
  "costing": "truck",
  "costing_options": { "truck": { "height": 4.0, "hazmat": true } }
}

The anchor is first, the not-yet-visited waypoints follow in order with their break and via types preserved, and the truck options survive, so the recalculated route is planned for the same hazmat vehicle as the original.

Guidance survives a tunnel, because it is tested against one

The replay harness ships a tunnel-dropout corpus: a motorway drive with a total signal loss between t=38s and t=61s, 23 seconds with no fixes emitted at all. The session holds its state across the gap, resumes on the far side and still records arrival, with no deviation recorded across the whole drive.

The core does not estimate position between fixes. With nothing arriving it advances nothing and guesses nothing, so a tunnel long enough to matter leaves guidance parked on the last step the vehicle was known to be on. For that case, advanceToNextStep on a GuidanceSession lets the app step the route manually rather than have the core pretend it knows where the vehicle is.

What it does not do

The honest list, because the gaps here are real ones.

  • The debounce controller is not exported over the FFI yet. It lives in the Rust core and is covered by its own unit tests, but Kotlin and Swift today see NavigationState.needsReroute, which is the raw, undebounced OffRoute verdict. Until it is exported, an app that wants the debounce implements it itself against those defaults.
  • Speed limits appear only when the data carries them. The limit is surfaced from the route's maxspeed annotation, which requires the graph to have been built with OSM maxspeed. When the route carries no annotation at the current position the core reports no limit at all rather than guessing, and the published @mapmap/core 0.1.0 does not ship the field yet.
  • The mobile SDKs need early access. The Android AAR and iOS XCFramework build from the same sdk-v* tags as the npm packages, but the source repository is private today. See SDKs and installation.
  • Offline downloads are metered separately from API calls. Provisional keys cannot download territory layers at all, and the free tier includes a 2 GiB monthly offline-download allowance: enough to put one region on one device to evaluate it, not enough to equip a fleet.

Try it

npm install @mapmap/core and run the snippet above. It needs no key, no network and no account, because the request builder and the ADR check are pure functions over your vehicle profile. From there, SDKs and installation covers the web, Android and iOS packages and the verifying key that anchors package trust, and ADR tunnel compliance covers what the tunnel codes actually restrict.

Routing derives from OpenStreetMap, so credit © OpenStreetMap contributors when you render or republish it.