Offline turn-by-turn navigation, ADR rules included

# 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](/news/territory-packages-ota) and the
[territories documentation](/docs/territories).

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

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

```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](/docs/sdks).
- **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](/docs/sdks) covers the web, Android and iOS packages and
the verifying key that anchors package trust, and
[ADR tunnel compliance](/news/adr-tunnel-compliance) covers what the tunnel
codes actually restrict.

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