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.
| Piece | What it does | Where it comes from |
|---|---|---|
| Territory package | Routing tiles, base-map tiles, a geocode index and ADR restriction overlays | Built by our map factory (snfactory), ed25519-signed, installed on disk |
| Routing engine | route(request_json) -> response_json | valhalla-mobile (MIT), injected by the platform |
Navigation core (sn-nav-core) | Request building, ADR merge, guidance state machine, off-route debounce | First-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:
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:
{
"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:
{
"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.
| Setting | Default | What it gates |
|---|---|---|
min_consecutive_offroute_fixes | 3 | Consecutive off-route fixes before a reroute can be confirmed |
min_offroute_duration_s | 5.0 | Seconds continuously off route before a reroute can be confirmed |
min_interval_between_reroutes_s | 10.0 | Cooldown after a reroute fires; reports CoolingDown instead of firing again |
route_deviation_threshold_m | 25.0 | Metres from the route line before guidance calls it off route at all |
minimum_horizontal_accuracy_m | 25 | Fixes 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:
{
"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, undebouncedOffRouteverdict. 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
maxspeedannotation, which requires the graph to have been built with OSMmaxspeed. When the route carries no annotation at the current position the core reports no limit at all rather than guessing, and the published@mapmap/core0.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.
