diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21544fa..b46c7cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+### Added
+
+- **Directions layer** — keyless A→B directions without a geocoder or a
+ microphone. The row's chips arm a globe click for A and B (DRIVE / WALK /
+ BIKE, SWAP, FLY, CLEAR); the route comes from the existing `/api/route`
+ proxy (OSRM on the FOSSGIS servers), is draped on terrain and 3D tiles with
+ the same flowing dashes as voice routes, and drops one dot per maneuver —
+ click one for the instruction. FLY rides the shared route-flight cinematic.
+ A route that cannot be found says so; no straight line is ever drawn as a
+ route. Share links carry the layer as token `n`.
+- `/api/route` now returns turn-by-turn steps when asked (`steps=1`), phrased
+ in plain English from OSRM's maneuver data (`src/data/routeSteps.js`). The
+ upstream is always asked for steps, so the annotation engine and the
+ Directions layer share one cached upstream call per route.
+- The Data attribution popover now credits OSRM / FOSSGIS routing (used by
+ voice routes since launch, previously uncredited).
+
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index faa351d..b7d8807 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -37,6 +37,7 @@ How to read this:
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
+| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for voice route annotations and the Directions layer, via `/api/route` | [FOSSGIS routing server terms](https://routing.openstreetmap.de/about.html): free, keyless, fair use (no bulk or heavy automated use; run your own OSRM for that); route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on FOSSGIS servers" + "© OpenStreetMap contributors" — registered in the Data attribution popover |
### Notes on the live sources
@@ -47,6 +48,7 @@ How to read this:
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
+- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
diff --git a/README.md b/README.md
index 86f69c5..1c8d331 100644
--- a/README.md
+++ b/README.md
@@ -262,7 +262,7 @@ Twenty-eight tools, four jobs — the commands below come straight from the prod
## 🛰️ What's on the Globe
-Thirteen layers and map sources. **Eleven have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
+Fourteen layers and map sources. **Twelve have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth |
|-------|--------------|--------|------|
@@ -276,6 +276,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
+| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 1f4d861..7df5372 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1621,11 +1621,36 @@ its criteria cannot be silently ignored.
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
+| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
+Directions is a keyless front end to the routing the voice agent already
+uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
+profile; SET A and SET B arm the next globe click (Escape or a second press
+cancels), placing clamped A/B markers; with both placed the layer requests
+`/api/route?…&steps=1`, drapes the geometry as a `ClassificationType.BOTH`
+ground polyline with the annotation renderer's flowing-dash material (so it
+reads on the keyless terrain globe and on 3D tiles alike), and drops one white
+point primitive per intermediate maneuver. Clicking a dot opens a protected
+shared-host card with the instruction, the leg after it, and the next
+instruction; Escape or a click on empty globe clears it. SWAP reverses the
+endpoints and reroutes; changing the mode reroutes; CLEAR removes everything.
+FLY hands the geometry to `flyRoute` (the same cinematic as voice `fly_route`)
+after an idempotent `initCameraVerbs` so no voice session is required. The row
+meta reads `OSM routing · 21 km · 25 min · Drive`; while routing it shows
+`Routing…`, and a router miss reads `No route found between A and B` — the
+layer never substitutes a straight line. The layer holds continuous render
+only while a route is drawn (the dashes animate). Disable clears all state.
+
+`/api/route` always asks OSRM for steps and caches the full response, serving
+the step list only when `steps=1` is requested; `src/data/routeSteps.js` turns
+OSRM maneuver type / modifier / exit / road name into one plain-English
+sentence per decision, folding `exit roundabout` steps into the roundabout
+they leave.
+
`src/data/militaryAwareness.js` remains registered internally as the Contacts
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
point is the right-side `CONTEXT` chooser's `CONTACTS` mode.
diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js
index 235bf0a..ca51244 100644
--- a/src/annotations/worldAnnotationRenderer.js
+++ b/src/annotations/worldAnnotationRenderer.js
@@ -323,7 +323,7 @@ let _flowFabricRegistered = false;
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
* can build the material the render pipeline uses. Constructing one Material with the
* fabric caches it under its type name. */
-function ensureFlowFabricRegistered() {
+export function ensureFlowFabricRegistered() {
if (_flowFabricRegistered) return;
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
_flowFabricRegistered = true;
@@ -339,7 +339,7 @@ function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
-function FlowMaterialProperty(colorCss) {
+export function FlowMaterialProperty(colorCss) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
this._definitionChanged = new Cesium.Event();
}
diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js
index 700ef1c..f4aa88e 100644
--- a/src/cameraVerbs.js
+++ b/src/cameraVerbs.js
@@ -968,11 +968,16 @@ function onTick() {
}
}
-/** Wire the module to the viewer once (idempotent). */
-export function initCameraVerbs(viewer, getViewTargetCartesian) {
+/**
+ * Wire the module to the viewer once (idempotent). The view-target getter is
+ * optional so a non-voice caller (the Directions FLY chip) can arm route
+ * flights before any voice session exists; a later call with a real getter
+ * installs it, and a call without one never clears an installed getter.
+ */
+export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
+ if (typeof getViewTargetCartesian === 'function') _getTarget = getViewTargetCartesian;
if (_viewer === viewer) return;
_viewer = viewer;
- _getTarget = getViewTargetCartesian;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
for (const rm of _inputRemovers) rm();
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index b35da9e..130c12e 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -131,6 +131,13 @@ export const DATA_CREDITS = [
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
},
+ {
+ key: 'osrm-routing',
+ html:
+ 'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
+ 'routing.openstreetmap.de · ' +
+ '© OpenStreetMap contributors (ODbL)',
+ },
{
key: 'radio-browser',
html:
diff --git a/src/data/directions.js b/src/data/directions.js
new file mode 100644
index 0000000..c5c2f29
--- /dev/null
+++ b/src/data/directions.js
@@ -0,0 +1,744 @@
+/**
+ * @module directions
+ * @description Keyless A→B directions on the globe: drive, walk or cycle.
+ *
+ * The row's chips arm a click ("SET A", then click the globe; "SET B", click
+ * again). With both ends placed the layer asks the existing `/api/route`
+ * proxy (OSRM on the FOSSGIS servers, © OpenStreetMap contributors) for the
+ * street-following route with turn-by-turn steps, drapes it on the terrain
+ * and 3D tiles with the same flowing dashes the voice "route from A to B"
+ * annotation uses, and drops one dot per maneuver. Click a dot for the
+ * instruction; "FLY" rides the camera along the route through the shared
+ * route-flight cinematic (`flyRoute`), the way voice's `fly_route` does.
+ *
+ * Needs no key, no geocoder and no microphone. A route that cannot be found
+ * says so — there is never a straight-line stand-in drawn as if it were a
+ * route.
+ */
+
+import * as Cesium from 'cesium';
+import {
+ governorRequestRender,
+ holdContinuousRender,
+ releaseContinuousRender,
+} from '../renderGovernor.js';
+import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js';
+import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
+import { isPickedWorldPosition } from './scenePick.js';
+import {
+ clearOverlaySource,
+ setOverlayEntries,
+ setOverlaySourceVisible,
+} from '../overlays/worldOverlay.js';
+import {
+ FlowMaterialProperty,
+ ensureFlowFabricRegistered,
+} from '../annotations/worldAnnotationRenderer.js';
+import { flyRoute, initCameraVerbs } from '../cameraVerbs.js';
+import { formatRouteDistance, formatRouteDuration } from './routeSteps.js';
+
+export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
+export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
+ cohortLimit: 1,
+ collisionCapacity: 0,
+ moving: false,
+});
+
+/** Travel modes, keyed by the `/api/route` profile name. */
+export const DIRECTIONS_MODES = Object.freeze({
+ car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
+ foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
+ bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
+});
+export const DEFAULT_DIRECTIONS_MODE = 'car';
+
+/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
+export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
+const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
+const MARKER_B_COLOR = Cesium.Color.fromCssColorString('#ff6b6b');
+const STEP_COLOR = Cesium.Color.WHITE.withAlpha(0.95);
+const STEP_OUTLINE = Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR);
+const STEP_PIXEL_SIZE = 8;
+const STEP_SELECTED_PIXEL_SIZE = 13;
+/** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */
+const ROUTE_TIMEOUT_MS = 15_000;
+
+const DEFAULT_OVERLAY_HOST = Object.freeze({
+ setEntries: setOverlayEntries,
+ setVisible: setOverlaySourceVisible,
+ clearSource: clearOverlaySource,
+});
+let _overlayHost = DEFAULT_OVERLAY_HOST;
+
+// --- Module state ---
+let _viewer = null;
+let _enabled = false;
+let _mode = DEFAULT_DIRECTIONS_MODE;
+/** @type {null|'a'|'b'} which endpoint the next globe click places */
+let _armed = null;
+/** @type {{lat:number, lon:number}|null} */
+let _a = null;
+/** @type {{lat:number, lon:number}|null} */
+let _b = null;
+/** @type {'idle'|'routing'|'ready'|'error'} */
+let _status = 'idle';
+let _error = null;
+/** @type {{distanceM:number, durationS:number, geometry:number[][], steps:object[], mode:string}|null} */
+let _route = null;
+let _routeSeq = 0;
+let _routeAbort = null;
+let _lastUpdate = null;
+let _markerA = null;
+let _markerB = null;
+let _routeEntity = null;
+let _stepPoints = null;
+let _selectedStep = null;
+let _clickHandler = null;
+let _renderHeld = false;
+let _rowControlsListener = null;
+let _dataManager = null;
+
+// ---------------------------------------------------------------------------
+// Pure helpers (exported for tests)
+// ---------------------------------------------------------------------------
+
+/**
+ * Validate a params patch. Returns null when it contains an unknown mode.
+ * @param {object} params
+ * @returns {{mode?: string, arm?: 'a'|'b'|null, swap?: boolean, fly?: boolean, clear?: boolean}|null}
+ */
+export function normalizeDirectionsParams(params = {}) {
+ const out = {};
+ if (params.mode !== undefined) {
+ const mode = String(params.mode).toLowerCase();
+ if (!DIRECTIONS_MODES[mode]) return null;
+ out.mode = mode;
+ }
+ if (params.arm !== undefined) {
+ out.arm = params.arm === 'a' || params.arm === 'b' ? params.arm : null;
+ }
+ if (params.swap === true) out.swap = true;
+ if (params.fly === true) out.fly = true;
+ if (params.clear === true) out.clear = true;
+ return out;
+}
+
+/**
+ * Row chips for a given state. Pure so the chip logic is testable without a DOM.
+ * @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
+ * @returns {{chips: object[], legend: object[]}}
+ */
+export function directionsRowControls(state) {
+ const { mode, armed, a, b, status, route } = state;
+ const routing = status === 'routing';
+ const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
+ id: `mode-${id}`,
+ label: spec.chip,
+ active: mode === id,
+ state: mode === id ? 'active' : 'idle',
+ title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
+ params: { mode: id },
+ }));
+ chips.push({
+ id: 'set-a',
+ label: armed === 'a' ? 'CLICK MAP' : (a ? 'A ✓' : 'SET A'),
+ active: armed === 'a',
+ state: armed === 'a' ? 'active' : 'idle',
+ title: armed === 'a' ? 'Click a spot on the globe to place A (click again to cancel)' : 'Then click the globe to place the start',
+ params: { arm: armed === 'a' ? null : 'a' },
+ });
+ chips.push({
+ id: 'set-b',
+ label: armed === 'b' ? 'CLICK MAP' : (b ? 'B ✓' : 'SET B'),
+ active: armed === 'b',
+ state: armed === 'b' ? 'active' : 'idle',
+ title: armed === 'b' ? 'Click a spot on the globe to place B (click again to cancel)' : 'Then click the globe to place the destination',
+ params: { arm: armed === 'b' ? null : 'b' },
+ });
+ chips.push({
+ id: 'swap',
+ label: '⇄',
+ disabled: !(a && b) || routing,
+ state: 'idle',
+ title: 'Swap A and B',
+ params: { swap: true },
+ });
+ chips.push({
+ id: 'fly',
+ label: routing ? 'FLY ···' : 'FLY',
+ disabled: !route || routing,
+ busy: routing,
+ state: routing ? 'loading' : 'idle',
+ title: route ? 'Fly the camera along the route' : 'Place A and B first',
+ params: { fly: true },
+ });
+ chips.push({
+ id: 'clear',
+ label: 'CLEAR',
+ disabled: !a && !b && !route,
+ state: 'idle',
+ title: 'Remove the route and both markers',
+ params: { clear: true },
+ });
+ return { chips, legend: [] };
+}
+
+/**
+ * Stats for the Data Layers row. Pure.
+ * @param {{enabled:boolean, mode:string, a:object|null, b:object|null, status:string, error:string|null, route:object|null, lastUpdate:number|null, armed:null|'a'|'b'}} state
+ * @returns {object}
+ */
+export function directionsStats(state) {
+ const { mode, a, b, status, error, route, lastUpdate, armed } = state;
+ const source = 'OSM routing';
+ if (status === 'routing') {
+ return { count: 0, lastUpdate, error: null, loading: true, loadingLabel: 'Routing…', source };
+ }
+ if (status === 'error') {
+ return { count: 0, lastUpdate, error: error || 'No route found', status: 'empty', source };
+ }
+ // The manager prints `loadingLabel` as the row's detail line whenever it is
+ // set (not only while loading), so the route summary and the placement
+ // guidance ride on it; `coverage` carries the same text for stats readers.
+ if (route) {
+ const word = DIRECTIONS_MODES[mode]?.word || mode;
+ const summary = `${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}`;
+ return {
+ count: route.steps.length,
+ lastUpdate,
+ error: null,
+ source,
+ coverage: summary,
+ loadingLabel: summary,
+ };
+ }
+ let coverage;
+ if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
+ else if (a && !b) coverage = 'SET B, then click the globe';
+ else if (!a && b) coverage = 'SET A, then click the globe';
+ else coverage = 'SET A, then click the globe';
+ return { count: 0, lastUpdate, error: null, status: 'idle', source, coverage, loadingLabel: coverage };
+}
+
+/**
+ * Card copy for one maneuver.
+ * @param {object[]} steps
+ * @param {number} index
+ * @returns {{title:string, details:string[]}}
+ */
+export function directionsStepCopy(steps, index) {
+ const step = steps[index];
+ const details = [];
+ const leg = [];
+ if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
+ if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
+ details.push(`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`);
+ const next = steps[index + 1];
+ if (next) details.push(`Then: ${next.instruction}`);
+ return { title: step.instruction, details };
+}
+
+/**
+ * Shared-host card for the selected maneuver.
+ * @param {number} index
+ * @param {Cesium.Cartesian3} position
+ * @param {{title:string, details:string[]}} copy
+ * @returns {object|null}
+ */
+export function createDirectionsStepOverlayEntry(index, position, copy) {
+ if (!Number.isInteger(index) || !position) return null;
+ return {
+ id: `directions-step-${index}`,
+ position,
+ variant: 'selected',
+ selected: true,
+ protected: true,
+ paintLane: 'selected',
+ collisionGroup: 'ambient-card',
+ priority: Number.MAX_SAFE_INTEGER,
+ title: copy.title,
+ details: copy.details,
+ accent: DIRECTIONS_ROUTE_COLOR,
+ interactive: false,
+ anchorRadiusPx: 9,
+ minAnchorGapPx: 11,
+ verticalOnly: true,
+ placement: 'above',
+ edgeFade: 'keyhole',
+ horizonCull: true,
+ terrainOcclusion: false,
+ };
+}
+
+/**
+ * Build the `/api/route` URL for two endpoints.
+ * @param {string} mode car | foot | bike
+ * @param {{lat:number, lon:number}} a
+ * @param {{lat:number, lon:number}} b
+ * @returns {string}
+ */
+export function directionsRequestUrl(mode, a, b) {
+ const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
+ return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
+}
+
+/**
+ * Validate a proxy payload into the route record the layer keeps, or null.
+ * @param {object} payload
+ * @param {string} mode
+ * @returns {{distanceM:number, durationS:number, geometry:number[][], steps:object[], mode:string}|null}
+ */
+export function normalizeRoutePayload(payload, mode) {
+ if (!payload || payload.ok !== true || !Array.isArray(payload.geometry) || payload.geometry.length < 2) return null;
+ const geometry = payload.geometry
+ .map((pair) => [Number(pair?.[0]), Number(pair?.[1])])
+ .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
+ if (geometry.length < 2) return null;
+ const steps = (Array.isArray(payload.steps) ? payload.steps : [])
+ .filter((step) => step && typeof step.instruction === 'string' && Number.isFinite(step.lat) && Number.isFinite(step.lon))
+ .map((step, index) => ({ ...step, index }));
+ return {
+ distanceM: Math.max(0, Number(payload.distanceM) || 0),
+ durationS: Math.max(0, Number(payload.durationS) || 0),
+ geometry,
+ steps,
+ mode,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Internals
+// ---------------------------------------------------------------------------
+
+function state() {
+ return { enabled: _enabled, mode: _mode, armed: _armed, a: _a, b: _b, status: _status, error: _error, route: _route, lastUpdate: _lastUpdate };
+}
+
+function notifyRow() {
+ try { _rowControlsListener?.(); } catch { /* listener is best-effort */ }
+ _dataManager?.refreshLayerStats?.();
+}
+
+function syncRenderHold() {
+ const shouldHold = _enabled && Boolean(_routeEntity);
+ if (shouldHold && !_renderHeld) {
+ holdContinuousRender('directions');
+ _renderHeld = true;
+ } else if (!shouldHold && _renderHeld) {
+ releaseContinuousRender('directions');
+ _renderHeld = false;
+ }
+}
+
+function pickGround(screenPosition) {
+ const scene = _viewer?.scene;
+ let cartesian = null;
+ if (scene?.pickPositionSupported && typeof scene.pickPosition === 'function') {
+ try { cartesian = scene.pickPosition(screenPosition); } catch { cartesian = null; }
+ }
+ if (!isPickedWorldPosition(cartesian) && typeof _viewer?.camera?.pickEllipsoid === 'function') {
+ try { cartesian = _viewer.camera.pickEllipsoid(screenPosition, Cesium.Ellipsoid.WGS84); } catch { cartesian = null; }
+ }
+ if (!isPickedWorldPosition(cartesian)) return null;
+ const carto = Cesium.Cartographic.fromCartesian(cartesian);
+ if (!carto) return null;
+ return { lat: Cesium.Math.toDegrees(carto.latitude), lon: Cesium.Math.toDegrees(carto.longitude) };
+}
+
+function markerEntity(letter, point, color) {
+ return _viewer.entities.add({
+ id: `directions:marker:${letter}`,
+ position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
+ point: {
+ pixelSize: 14,
+ color,
+ outlineColor: Cesium.Color.BLACK,
+ outlineWidth: 2,
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
+ },
+ label: {
+ text: letter,
+ font: 'bold 13px "JetBrains Mono", "SF Mono", monospace',
+ fillColor: Cesium.Color.WHITE,
+ outlineColor: Cesium.Color.BLACK,
+ outlineWidth: 3,
+ style: Cesium.LabelStyle.FILL_AND_OUTLINE,
+ pixelOffset: new Cesium.Cartesian2(0, -20),
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
+ },
+ });
+}
+
+function removeEntity(entity) {
+ if (entity && _viewer && !_viewer.isDestroyed?.()) _viewer.entities.remove(entity);
+}
+
+function placeMarker(letter, point) {
+ if (!_viewer) return;
+ if (letter === 'a') {
+ removeEntity(_markerA);
+ _markerA = markerEntity('A', point, MARKER_A_COLOR);
+ } else {
+ removeEntity(_markerB);
+ _markerB = markerEntity('B', point, MARKER_B_COLOR);
+ }
+}
+
+function clearRouteGraphics() {
+ _clearStepSelection();
+ removeEntity(_routeEntity);
+ _routeEntity = null;
+ _stepPoints?.removeAll();
+ syncRenderHold();
+}
+
+function drawRoute(route) {
+ clearRouteGraphics();
+ if (!_viewer) return;
+ ensureFlowFabricRegistered();
+ const positions = Cesium.Cartesian3.fromDegreesArray(route.geometry.flat());
+ _routeEntity = _viewer.entities.add({
+ id: 'directions:route',
+ polyline: {
+ positions,
+ width: 9,
+ material: new FlowMaterialProperty(DIRECTIONS_ROUTE_COLOR),
+ clampToGround: true,
+ // BOTH: drape on 3D tiles when they are up and on terrain when they are
+ // not, so the keyless globe shows the route too.
+ classificationType: Cesium.ClassificationType.BOTH,
+ },
+ });
+ // One dot per decision; A and B already mark departure and arrival.
+ route.steps.forEach((step, index) => {
+ if (index === 0 || index === route.steps.length - 1) return;
+ _stepPoints.add({
+ id: `directions:step:${index}`,
+ position: Cesium.Cartesian3.fromDegrees(step.lon, step.lat, 2),
+ color: STEP_COLOR,
+ pixelSize: STEP_PIXEL_SIZE,
+ outlineColor: STEP_OUTLINE,
+ outlineWidth: 2,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
+ });
+ });
+ syncRenderHold();
+ restoreSpriteOrder(_viewer);
+ governorRequestRender('directions-route');
+}
+
+async function requestRoute() {
+ if (!_a || !_b || !_enabled) return;
+ _routeAbort?.abort();
+ const controller = new AbortController();
+ _routeAbort = controller;
+ const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
+ _routeSeq += 1;
+ const seq = _routeSeq;
+ const mode = _mode;
+ _status = 'routing';
+ _error = null;
+ notifyRow();
+ try {
+ const response = await fetch(directionsRequestUrl(mode, _a, _b), {
+ signal: controller.signal,
+ headers: { Accept: 'application/json' },
+ });
+ if (response.status === 429) throw new Error('Routing is rate limited — try again in a moment');
+ const payload = await response.json();
+ if (seq !== _routeSeq || !_enabled) return;
+ const route = normalizeRoutePayload(payload, mode);
+ if (!route) {
+ _route = null;
+ clearRouteGraphics();
+ _status = 'error';
+ _error = payload?.error === 'no route found' ? 'No route found between A and B' : (payload?.error ? `Routing failed: ${payload.error}` : 'No route found between A and B');
+ return;
+ }
+ _route = route;
+ _status = 'ready';
+ _lastUpdate = Date.now();
+ drawRoute(route);
+ } catch (error) {
+ if (seq !== _routeSeq || !_enabled) return;
+ _route = null;
+ clearRouteGraphics();
+ _status = 'error';
+ _error = error?.name === 'AbortError' ? 'Routing timed out' : (error?.message || 'Routing unavailable');
+ } finally {
+ clearTimeout(timer);
+ if (_routeAbort === controller) _routeAbort = null;
+ if (seq === _routeSeq) notifyRow();
+ governorRequestRender('directions-route');
+ }
+}
+
+function clearAll() {
+ _routeAbort?.abort();
+ _routeAbort = null;
+ _routeSeq += 1;
+ _armed = null;
+ _a = null;
+ _b = null;
+ _route = null;
+ _status = 'idle';
+ _error = null;
+ removeEntity(_markerA);
+ removeEntity(_markerB);
+ _markerA = null;
+ _markerB = null;
+ clearRouteGraphics();
+ governorRequestRender('directions-clear');
+}
+
+function flyCurrentRoute() {
+ if (!_route || !_viewer) return false;
+ initCameraVerbs(_viewer);
+ const result = flyRoute([{
+ type: 'route',
+ label: 'Directions',
+ path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
+ }], { speed: 'normal' });
+ if (result?.ok !== true) console.warn('[Data:Directions] fly refused:', result?.error || result);
+ return result?.ok === true;
+}
+
+// --- Step selection ---
+
+function _clearStepSelection() {
+ if (_selectedStep !== null && _stepPoints) {
+ const point = findStepPoint(_selectedStep);
+ if (point) point.pixelSize = STEP_PIXEL_SIZE;
+ }
+ _selectedStep = null;
+ _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
+}
+
+function findStepPoint(index) {
+ if (!_stepPoints) return null;
+ const id = `directions:step:${index}`;
+ for (let i = 0; i < _stepPoints.length; i += 1) {
+ const point = _stepPoints.get(i);
+ if (point.id === id) return point;
+ }
+ return null;
+}
+
+function _selectStep(index) {
+ _clearStepSelection();
+ if (!_route || !Number.isInteger(index) || !_route.steps[index]) return;
+ const point = findStepPoint(index);
+ if (!point) return;
+ _selectedStep = index;
+ point.pixelSize = STEP_SELECTED_PIXEL_SIZE;
+ const entry = createDirectionsStepOverlayEntry(index, Cesium.Cartesian3.clone(point.position), directionsStepCopy(_route.steps, index));
+ if (entry) _overlayHost.setEntries(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, [entry], DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS);
+ governorRequestRender('directions-select');
+}
+
+function stepIndexFromPick(picked) {
+ const candidates = [picked?.primitive?.id, picked?.id];
+ for (const candidate of candidates) {
+ if (typeof candidate !== 'string') continue;
+ const match = /^directions:step:(\d+)$/.exec(candidate);
+ if (match) return Number(match[1]);
+ }
+ return null;
+}
+
+function _onKeyDown(event) {
+ if (event.key !== 'Escape') return;
+ if (_armed) {
+ _armed = null;
+ notifyRow();
+ } else if (_selectedStep !== null) {
+ _clearStepSelection();
+ }
+}
+
+function _installClickHandler(viewer) {
+ if (_clickHandler) return;
+ _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
+ _clickHandler.setInputAction((click) => {
+ if (!_enabled) return;
+ if (_armed) {
+ const point = pickGround(click.position);
+ if (!point) return;
+ const which = _armed;
+ _armed = null;
+ if (which === 'a') _a = point; else _b = point;
+ placeMarker(which, point);
+ if (_a && _b) void requestRoute();
+ else notifyRow();
+ governorRequestRender('directions-place');
+ return;
+ }
+ let picked = null;
+ try { picked = viewer.scene.pick(click.position); } catch { picked = null; }
+ const index = stepIndexFromPick(picked);
+ if (index !== null) {
+ _selectStep(index);
+ return;
+ }
+ if (_selectedStep !== null) _clearStepSelection();
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+ document.addEventListener('keydown', _onKeyDown);
+}
+
+function _removeClickHandler() {
+ if (_clickHandler) {
+ _clickHandler.destroy();
+ _clickHandler = null;
+ }
+ document.removeEventListener('keydown', _onKeyDown);
+}
+
+// ---------------------------------------------------------------------------
+// Layer module
+// ---------------------------------------------------------------------------
+
+const directionsLayer = {
+ id: 'directions',
+ name: 'Directions',
+ icon: '🧭',
+ source: 'OSM routing',
+ updateInterval: 0,
+
+ /**
+ * Create the maneuver-dot collection. Called once at bootstrap.
+ * @param {Cesium.Viewer} viewer
+ */
+ init(viewer) {
+ _viewer = viewer;
+ _stepPoints = new Cesium.PointPrimitiveCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT });
+ viewer.scene.primitives.add(_stepPoints);
+ registerSpriteCollection('directions', _stepPoints);
+ _stepPoints.show = false;
+ _enabled = false;
+ _mode = DEFAULT_DIRECTIONS_MODE;
+ _armed = null;
+ _a = null;
+ _b = null;
+ _route = null;
+ _status = 'idle';
+ _error = null;
+ _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
+ restoreSpriteOrder(viewer);
+ console.log('[Data:Directions] Initialized');
+ },
+
+ /**
+ * Show the row chips and listen for placement clicks.
+ * @param {Cesium.Viewer} viewer
+ */
+ enable(viewer) {
+ _enabled = true;
+ _stepPoints.show = true;
+ _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
+ _installClickHandler(viewer);
+ registerPickOwner('directions', (pickedId) => typeof pickedId === 'string' && pickedId.startsWith('directions:'));
+ syncRenderHold();
+ restoreSpriteOrder(viewer);
+ },
+
+ /**
+ * Remove the route, markers and listeners.
+ * @param {Cesium.Viewer} viewer
+ */
+ disable(viewer) {
+ _enabled = false;
+ clearAll();
+ _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
+ _removeClickHandler();
+ unregisterPickOwner('directions');
+ if (_stepPoints) _stepPoints.show = false;
+ syncRenderHold();
+ void viewer;
+ },
+
+ /** Nothing to poll: routes are requested on placement and mode change. */
+ async update() {},
+
+ /**
+ * Chip and programmatic writes. `mode` is state; `arm` is state (which
+ * endpoint the next globe click places); `swap`, `fly` and `clear` are
+ * one-shot commands that leave no param behind.
+ * @param {object} params
+ * @returns {boolean} false when the patch names an unknown mode.
+ */
+ setParams(params = {}) {
+ const patch = normalizeDirectionsParams(params);
+ if (!patch) return false;
+ if (patch.clear) clearAll();
+ if (patch.mode !== undefined && patch.mode !== _mode) {
+ _mode = patch.mode;
+ if (_a && _b && _enabled) void requestRoute();
+ }
+ if (patch.arm !== undefined) {
+ _armed = patch.arm;
+ if (_armed) _clearStepSelection();
+ }
+ if (patch.swap && _a && _b) {
+ [_a, _b] = [_b, _a];
+ placeMarker('a', _a);
+ placeMarker('b', _b);
+ if (_enabled) void requestRoute();
+ }
+ if (patch.fly) flyCurrentRoute();
+ notifyRow();
+ governorRequestRender('directions-params');
+ return true;
+ },
+
+ getParams() {
+ return { mode: _mode };
+ },
+
+ getRowControls() {
+ return directionsRowControls(state());
+ },
+
+ /**
+ * Install the manager's row re-render callback (placement and routing land
+ * outside any manager tick).
+ * @param {(() => void)|null} listener
+ */
+ setRowControlsListener(listener) {
+ _rowControlsListener = typeof listener === 'function' ? listener : null;
+ },
+
+ getStats() {
+ return directionsStats(state());
+ },
+
+ /**
+ * Keep a manager handle so placement and routing can repaint the row.
+ * @param {object} dataManager DataLayerManager instance.
+ */
+ attachDataManager(dataManager) {
+ _dataManager = dataManager;
+ },
+
+ /**
+ * Tear down entirely.
+ * @param {Cesium.Viewer} viewer
+ */
+ destroy(viewer) {
+ if (_enabled) this.disable(viewer);
+ if (_stepPoints) {
+ viewer.scene.primitives.remove(_stepPoints);
+ _stepPoints = null;
+ }
+ _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
+ _viewer = null;
+ },
+};
+
+/** Test seam: swap the shared overlay host. */
+export function _setDirectionsOverlayHostForTest(host) {
+ _overlayHost = host || DEFAULT_OVERLAY_HOST;
+}
+
+export default directionsLayer;
diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs
new file mode 100644
index 0000000..46c996a
--- /dev/null
+++ b/src/data/directions.test.mjs
@@ -0,0 +1,133 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as Cesium from 'cesium';
+import directionsLayer, {
+ DEFAULT_DIRECTIONS_MODE,
+ DIRECTIONS_MODES,
+ DIRECTIONS_ROUTE_COLOR,
+ createDirectionsStepOverlayEntry,
+ directionsRequestUrl,
+ directionsRowControls,
+ directionsStats,
+ directionsStepCopy,
+ normalizeDirectionsParams,
+ normalizeRoutePayload,
+} from './directions.js';
+import { LAYER_STATE_REGISTRY } from './layerState.js';
+import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
+
+const idle = { enabled: true, mode: 'car', armed: null, a: null, b: null, status: 'idle', error: null, route: null, lastUpdate: null };
+const A = { lat: 60.1699, lon: 24.9384 };
+const B = { lat: 60.2055, lon: 24.6559 };
+const route = {
+ distanceM: 21479,
+ durationS: 1478,
+ geometry: [[24.9384, 60.1699], [24.6559, 60.2055]],
+ steps: [
+ { index: 0, instruction: 'Head out on Kaivokatu', distanceM: 8, durationS: 1, lat: 60.1699, lon: 24.9384 },
+ { index: 1, instruction: 'Turn right onto Kansakoulukatu', distanceM: 236, durationS: 39, lat: 60.1683, lon: 24.9349 },
+ { index: 2, instruction: 'Arrive at your destination', distanceM: 0, durationS: 0, lat: 60.2055, lon: 24.6559 },
+ ],
+ mode: 'car',
+};
+
+test('layer module declares the manager contract, params, and row controls', () => {
+ assert.equal(directionsLayer.id, 'directions');
+ assert.equal(directionsLayer.updateInterval, 0);
+ for (const method of ['init', 'enable', 'disable', 'update', 'getStats', 'destroy', 'setParams', 'getParams', 'getRowControls', 'setRowControlsListener', 'attachDataManager']) {
+ assert.equal(typeof directionsLayer[method], 'function', `${method} is implemented`);
+ }
+ assert.deepEqual(directionsLayer.getParams(), { mode: DEFAULT_DIRECTIONS_MODE });
+ const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'directions');
+ assert.ok(entry, 'directions has a share-link token');
+ assert.equal(LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length, 1);
+ assert.ok(SPRITE_LAYER_ORDER.includes('directions'));
+});
+
+test('params: unknown modes are rejected, commands and arm state are normalized', () => {
+ assert.deepEqual(normalizeDirectionsParams({ mode: 'FOOT' }), { mode: 'foot' });
+ assert.equal(normalizeDirectionsParams({ mode: 'rocket' }), null);
+ assert.deepEqual(normalizeDirectionsParams({ arm: 'a' }), { arm: 'a' });
+ assert.deepEqual(normalizeDirectionsParams({ arm: 'zzz' }), { arm: null });
+ assert.deepEqual(normalizeDirectionsParams({ swap: true, fly: true, clear: true }), { swap: true, fly: true, clear: true });
+ assert.deepEqual(normalizeDirectionsParams({ swap: 'yes' }), {});
+ assert.deepEqual(normalizeDirectionsParams(), {});
+ // The live module refuses an unknown mode without touching state.
+ assert.equal(directionsLayer.setParams({ mode: 'rocket' }), false);
+ assert.deepEqual(directionsLayer.getParams(), { mode: DEFAULT_DIRECTIONS_MODE });
+});
+
+test('row chips: modes, arming, and command availability follow the state', () => {
+ const { chips } = directionsRowControls(idle);
+ const ids = chips.map((c) => c.id);
+ assert.deepEqual(ids, ['mode-car', 'mode-foot', 'mode-bike', 'set-a', 'set-b', 'swap', 'fly', 'clear']);
+ assert.equal(chips.find((c) => c.id === 'mode-car').active, true);
+ assert.equal(chips.find((c) => c.id === 'set-a').label, 'SET A');
+ assert.deepEqual(chips.find((c) => c.id === 'set-a').params, { arm: 'a' });
+ assert.equal(chips.find((c) => c.id === 'fly').disabled, true);
+ assert.equal(chips.find((c) => c.id === 'swap').disabled, true);
+ assert.equal(chips.find((c) => c.id === 'clear').disabled, true);
+
+ const armed = directionsRowControls({ ...idle, armed: 'a' }).chips;
+ assert.equal(armed.find((c) => c.id === 'set-a').label, 'CLICK MAP');
+ assert.deepEqual(armed.find((c) => c.id === 'set-a').params, { arm: null }, 'clicking again cancels');
+
+ const ready = directionsRowControls({ ...idle, a: A, b: B, status: 'ready', route }).chips;
+ assert.equal(ready.find((c) => c.id === 'set-a').label, 'A ✓');
+ assert.equal(ready.find((c) => c.id === 'fly').disabled, false);
+ assert.equal(ready.find((c) => c.id === 'swap').disabled, false);
+ assert.equal(ready.find((c) => c.id === 'clear').disabled, false);
+
+ const routing = directionsRowControls({ ...idle, a: A, b: B, status: 'routing' }).chips;
+ assert.equal(routing.find((c) => c.id === 'fly').busy, true);
+ for (const mode of Object.keys(DIRECTIONS_MODES)) assert.ok(ids.includes(`mode-${mode}`));
+});
+
+test('stats guide the user, show progress, and summarize a route honestly', () => {
+ assert.equal(directionsStats(idle).coverage, 'SET A, then click the globe');
+ assert.equal(directionsStats({ ...idle, armed: 'b' }).coverage, 'Click the globe to place B');
+ assert.equal(directionsStats({ ...idle, a: A }).coverage, 'SET B, then click the globe');
+ const routing = directionsStats({ ...idle, a: A, b: B, status: 'routing' });
+ assert.equal(routing.loading, true);
+ const ready = directionsStats({ ...idle, a: A, b: B, status: 'ready', route, lastUpdate: 5 });
+ assert.equal(ready.count, 3);
+ assert.equal(ready.coverage, '21 km · 25 min · Drive');
+ assert.equal(ready.loadingLabel, '21 km · 25 min · Drive', 'the summary is the row detail line');
+ assert.equal(ready.loading, undefined);
+ assert.equal(directionsStats(idle).loadingLabel, 'SET A, then click the globe');
+ const failed = directionsStats({ ...idle, a: A, b: B, status: 'error', error: 'No route found between A and B' });
+ assert.equal(failed.error, 'No route found between A and B');
+ assert.equal(failed.count, 0);
+ assert.equal(directionsLayer.getStats().count, 0);
+});
+
+test('step cards name the maneuver, its leg, and what comes next', () => {
+ const copy = directionsStepCopy(route.steps, 1);
+ assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
+ assert.deepEqual(copy.details, ['Step 2 of 3 · then 240 m · 39 s', 'Then: Arrive at your destination']);
+ const last = directionsStepCopy(route.steps, 2);
+ assert.deepEqual(last.details, ['Step 3 of 3']);
+ const position = Cesium.Cartesian3.fromDegrees(24.9349, 60.1683, 2);
+ const card = createDirectionsStepOverlayEntry(1, position, copy);
+ assert.equal(card.accent, DIRECTIONS_ROUTE_COLOR);
+ assert.equal(card.id, 'directions-step-1');
+ assert.equal(createDirectionsStepOverlayEntry(null, position, copy), null);
+});
+
+test('the proxy request carries both endpoints, the profile, and asks for steps', () => {
+ const url = directionsRequestUrl('foot', A, B);
+ assert.match(url, /^\/api\/route\?profile=foot&coords=24\.938400%2C60\.169900%3B24\.655900%2C60\.205500&steps=1$/);
+});
+
+test('route payloads are validated; a failed route never becomes a straight line', () => {
+ const ok = normalizeRoutePayload({ ok: true, distanceM: 21479, durationS: 1478, geometry: route.geometry, steps: route.steps }, 'car');
+ assert.equal(ok.distanceM, 21479);
+ assert.equal(ok.steps.length, 3);
+ assert.equal(ok.mode, 'car');
+ assert.equal(normalizeRoutePayload({ ok: false, error: 'no route found' }, 'car'), null);
+ assert.equal(normalizeRoutePayload({ ok: true, geometry: [[1, 1]] }, 'car'), null);
+ assert.equal(normalizeRoutePayload({ ok: true, geometry: [[999, 1], [1, 1]] }, 'car'), null);
+ const noSteps = normalizeRoutePayload({ ok: true, geometry: route.geometry }, 'bike');
+ assert.deepEqual(noSteps.steps, []);
+ assert.equal(normalizeRoutePayload(null, 'car'), null);
+});
diff --git a/src/data/layerState.js b/src/data/layerState.js
index eb68c14..9f05c7d 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -278,6 +278,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
+ Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 2c282e7..2cb4ed2 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -155,8 +155,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
- assert.equal(REGISTERED_LAYER_IDS.length, 16);
- assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
+ assert.equal(REGISTERED_LAYER_IDS.length, 17);
+ assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
diff --git a/src/data/manager.js b/src/data/manager.js
index 4163518..f29209b 100644
--- a/src/data/manager.js
+++ b/src/data/manager.js
@@ -2176,6 +2176,16 @@ export class DataLayerManager {
}
}
+ /**
+ * Repaint the toggle panel now. For layers whose data arrives outside their
+ * manager tick (camera-driven loads, click placement), so a row shows its
+ * state when the data lands instead of at the next interval.
+ * One DOM pass; skipped while the document is hidden.
+ */
+ refreshLayerStats() {
+ this._refreshTogglePanel();
+ }
+
_refreshTogglePanel() {
if (!this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js
new file mode 100644
index 0000000..aac9f8b
--- /dev/null
+++ b/src/data/routeSteps.js
@@ -0,0 +1,194 @@
+/**
+ * @module routeSteps
+ * @description Turn-by-turn steps from an OSRM route, in plain English.
+ *
+ * OSRM (the FOSSGIS servers behind `/api/route`) returns each step as a
+ * maneuver `type` + `modifier` (+ roundabout `exit`) and the road name; it
+ * does not phrase instructions. This module does, with the same vocabulary
+ * OSRM's own text-instructions package uses so a reader of either feels at
+ * home. Pure: used by the Vite proxy and node:test.
+ *
+ * OSRM maneuver types handled: depart, arrive, turn, new name, continue,
+ * end of road, fork, merge, on ramp, off ramp, roundabout, rotary,
+ * roundabout turn, exit roundabout, exit rotary, notification, use lane.
+ */
+
+/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
+export const ROUTE_STEPS_MAX = 200;
+
+const DIRECTION_WORDS = Object.freeze({
+ left: 'left',
+ right: 'right',
+ 'slight left': 'slightly left',
+ 'slight right': 'slightly right',
+ 'sharp left': 'sharply left',
+ 'sharp right': 'sharply right',
+ straight: 'straight',
+ uturn: 'around',
+});
+
+function cleanText(value) {
+ const text = typeof value === 'string' ? value.trim() : '';
+ return text;
+}
+
+/**
+ * Road label for a step: "Name (Ref)", "Name", "Ref", or "".
+ * @param {{name?: string, ref?: string}} step
+ * @returns {string}
+ */
+export function roadLabel(step) {
+ const name = cleanText(step?.name);
+ const ref = cleanText(step?.ref);
+ if (name && ref && !name.includes(ref)) return `${name} (${ref})`;
+ return name || ref;
+}
+
+function ontoRoad(step) {
+ const road = roadLabel(step);
+ return road ? ` onto ${road}` : '';
+}
+
+function direction(modifier) {
+ const key = cleanText(modifier).toLowerCase();
+ return DIRECTION_WORDS[key] || key;
+}
+
+function ordinal(n) {
+ if (!Number.isInteger(n) || n < 1) return null;
+ const suffix = (n % 100 >= 11 && n % 100 <= 13) ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th');
+ return `${n}${suffix}`;
+}
+
+/**
+ * One English sentence for an OSRM step.
+ * @param {{type?: string, modifier?: string, exit?: number, name?: string, ref?: string}} step
+ * @returns {string}
+ */
+export function instructionFor(step) {
+ const type = cleanText(step?.type).toLowerCase();
+ const modifier = cleanText(step?.modifier).toLowerCase();
+ const dir = direction(modifier);
+ const onto = ontoRoad(step);
+ const road = roadLabel(step);
+ switch (type) {
+ case 'depart':
+ return road ? `Head out on ${road}` : 'Head out';
+ case 'arrive':
+ if (modifier === 'left' || modifier === 'right') return `Arrive at your destination, on the ${modifier}`;
+ return 'Arrive at your destination';
+ case 'turn':
+ if (modifier === 'uturn') return `Make a U-turn${onto}`;
+ if (modifier === 'straight') return `Continue straight${onto}`;
+ return dir ? `Turn ${dir}${onto}` : `Turn${onto}`;
+ case 'new name':
+ return `Continue${onto}`;
+ case 'continue':
+ if (modifier === 'uturn') return `Make a U-turn${onto}`;
+ return dir && dir !== 'straight' ? `Continue ${dir}${onto}` : `Continue straight${onto}`;
+ case 'end of road':
+ return dir ? `At the end of the road, turn ${dir}${onto}` : `At the end of the road, continue${onto}`;
+ case 'fork':
+ return dir ? `Keep ${dir} at the fork${onto}` : `Continue at the fork${onto}`;
+ case 'merge':
+ return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
+ case 'on ramp':
+ return dir ? `Take the ramp on the ${dir}${onto}` : `Take the ramp${onto}`;
+ case 'off ramp':
+ return dir ? `Take the exit on the ${dir}${onto}` : `Take the exit${onto}`;
+ case 'roundabout':
+ case 'rotary': {
+ const exit = ordinal(step?.exit);
+ return exit ? `At the roundabout, take the ${exit} exit${onto}` : `Enter the roundabout${onto}`;
+ }
+ case 'roundabout turn':
+ return dir ? `At the roundabout, turn ${dir}${onto}` : `At the roundabout, continue${onto}`;
+ case 'exit roundabout':
+ case 'exit rotary':
+ return `Exit the roundabout${onto}`;
+ case 'use lane':
+ return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`;
+ case 'notification':
+ default:
+ return `Continue${onto}`;
+ }
+}
+
+/**
+ * Flatten an OSRM route's legs into the compact step list the proxy serves.
+ * "exit roundabout" / "exit rotary" steps are folded into the roundabout
+ * step before them (their distance is the road after the roundabout), so a
+ * reader gets one instruction per decision.
+ * @param {{legs?: Array<{steps?: object[]}>}} route OSRM route object.
+ * @returns {Array<{index:number, type:string, modifier:string|null, exit:number|null,
+ * name:string, ref:string|null, distanceM:number, durationS:number, lon:number, lat:number, instruction:string}>}
+ */
+export function normalizeOsrmSteps(route) {
+ const out = [];
+ for (const leg of route?.legs || []) {
+ for (const raw of leg?.steps || []) {
+ const location = raw?.maneuver?.location;
+ const lon = Number(location?.[0]);
+ const lat = Number(location?.[1]);
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
+ const type = cleanText(raw.maneuver?.type).toLowerCase() || 'continue';
+ const step = {
+ index: out.length,
+ type,
+ modifier: cleanText(raw.maneuver?.modifier).toLowerCase() || null,
+ exit: Number.isInteger(raw.maneuver?.exit) ? raw.maneuver.exit : null,
+ name: cleanText(raw.name),
+ ref: cleanText(raw.ref) || null,
+ distanceM: Math.max(0, Math.round(Number(raw.distance) || 0)),
+ durationS: Math.max(0, Math.round(Number(raw.duration) || 0)),
+ lon: Number(lon.toFixed(6)),
+ lat: Number(lat.toFixed(6)),
+ instruction: '',
+ };
+ const previous = out[out.length - 1];
+ if ((type === 'exit roundabout' || type === 'exit rotary') && previous
+ && (previous.type === 'roundabout' || previous.type === 'rotary' || previous.type === 'roundabout turn')) {
+ previous.distanceM += step.distanceM;
+ previous.durationS += step.durationS;
+ if (!roadLabel(previous) && roadLabel(step)) {
+ previous.name = step.name;
+ previous.ref = step.ref;
+ previous.instruction = instructionFor(previous);
+ }
+ continue;
+ }
+ step.instruction = instructionFor(step);
+ out.push(step);
+ if (out.length >= ROUTE_STEPS_MAX) return out;
+ }
+ }
+ return out;
+}
+
+/**
+ * "850 m", "1.2 km", "21 km".
+ * @param {number} meters
+ * @returns {string}
+ */
+export function formatRouteDistance(meters) {
+ if (!Number.isFinite(meters) || meters < 0) return '';
+ if (meters < 100) return `${Math.round(meters)} m`;
+ if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
+ const km = meters / 1000;
+ return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
+}
+
+/**
+ * "40 s", "12 min", "1 h 5 min".
+ * @param {number} seconds
+ * @returns {string}
+ */
+export function formatRouteDuration(seconds) {
+ if (!Number.isFinite(seconds) || seconds < 0) return '';
+ if (seconds < 60) return `${Math.round(seconds)} s`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes} min`;
+ const hours = Math.floor(minutes / 60);
+ const rest = minutes % 60;
+ return rest ? `${hours} h ${rest} min` : `${hours} h`;
+}
diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs
new file mode 100644
index 0000000..5d9652d
--- /dev/null
+++ b/src/data/routeSteps.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ ROUTE_STEPS_MAX,
+ formatRouteDistance,
+ formatRouteDuration,
+ instructionFor,
+ normalizeOsrmSteps,
+ roadLabel,
+} from './routeSteps.js';
+
+test('instructions read like a navigator, one sentence per OSRM maneuver', () => {
+ assert.equal(instructionFor({ type: 'depart', name: 'Kaivokatu' }), 'Head out on Kaivokatu');
+ assert.equal(instructionFor({ type: 'depart' }), 'Head out');
+ assert.equal(instructionFor({ type: 'new name', modifier: 'straight', name: 'Simonkatu' }), 'Continue onto Simonkatu');
+ assert.equal(instructionFor({ type: 'end of road', modifier: 'left', name: 'Annankatu' }), 'At the end of the road, turn left onto Annankatu');
+ assert.equal(instructionFor({ type: 'turn', modifier: 'right', name: 'Kansakoulukatu' }), 'Turn right onto Kansakoulukatu');
+ assert.equal(instructionFor({ type: 'turn', modifier: 'slight left', name: 'Ring I', ref: 'Kt 50' }), 'Turn slightly left onto Ring I (Kt 50)');
+ assert.equal(instructionFor({ type: 'turn', modifier: 'uturn' }), 'Make a U-turn');
+ assert.equal(instructionFor({ type: 'roundabout', modifier: 'right', exit: 2, name: 'Malminrinne' }), 'At the roundabout, take the 2nd exit onto Malminrinne');
+ assert.equal(instructionFor({ type: 'rotary', exit: 1 }), 'At the roundabout, take the 1st exit');
+ assert.equal(instructionFor({ type: 'roundabout', exit: 11 }), 'At the roundabout, take the 11th exit');
+ assert.equal(instructionFor({ type: 'fork', modifier: 'slight right', ref: 'E12' }), 'Keep slightly right at the fork onto E12');
+ assert.equal(instructionFor({ type: 'off ramp', modifier: 'right', name: 'Turunväylä' }), 'Take the exit on the right onto Turunväylä');
+ assert.equal(instructionFor({ type: 'on ramp', modifier: 'left' }), 'Take the ramp on the left');
+ assert.equal(instructionFor({ type: 'merge', modifier: 'slight left', name: 'Länsiväylä' }), 'Merge slightly left onto Länsiväylä');
+ assert.equal(instructionFor({ type: 'arrive', modifier: 'right' }), 'Arrive at your destination, on the right');
+ assert.equal(instructionFor({ type: 'arrive' }), 'Arrive at your destination');
+ assert.equal(instructionFor({ type: 'made-up', name: 'X' }), 'Continue onto X');
+ assert.equal(instructionFor(null), 'Continue');
+});
+
+test('road label prefers "Name (Ref)" and never repeats a ref already in the name', () => {
+ assert.equal(roadLabel({ name: 'Ring I', ref: 'Kt 50' }), 'Ring I (Kt 50)');
+ assert.equal(roadLabel({ name: 'E12 Turunväylä', ref: 'E12' }), 'E12 Turunväylä');
+ assert.equal(roadLabel({ ref: 'E12' }), 'E12');
+ assert.equal(roadLabel({}), '');
+});
+
+test('normalizeOsrmSteps flattens legs, folds roundabout exits, and drops steps without a location', () => {
+ const route = {
+ legs: [{
+ steps: [
+ { maneuver: { type: 'depart', location: [24.9384, 60.1699] }, name: 'Kaivokatu', distance: 8.3, duration: 1.3 },
+ { maneuver: { type: 'roundabout', modifier: 'right', exit: 2, location: [24.9313, 60.1675] }, name: '', distance: 34.7, duration: 7.3 },
+ { maneuver: { type: 'exit roundabout', modifier: 'slight right', exit: 2, location: [24.931, 60.1674] }, name: 'Malminrinne', distance: 100.8, duration: 17.1 },
+ { maneuver: { type: 'turn', modifier: 'left' }, name: 'Nowhere', distance: 5, duration: 1 },
+ ],
+ }, {
+ steps: [
+ { maneuver: { type: 'arrive', modifier: 'left', location: [24.6559, 60.2055] }, name: 'Kirkkojärventie', distance: 0, duration: 0 },
+ ],
+ }],
+ };
+ const steps = normalizeOsrmSteps(route);
+ assert.deepEqual(steps.map((s) => s.type), ['depart', 'roundabout', 'arrive']);
+ assert.equal(steps[1].distanceM, 136);
+ assert.equal(steps[1].durationS, 24);
+ assert.equal(steps[1].instruction, 'At the roundabout, take the 2nd exit onto Malminrinne');
+ assert.deepEqual(steps.map((s) => s.index), [0, 1, 2]);
+ assert.equal(steps[2].instruction, 'Arrive at your destination, on the left');
+ assert.equal(steps[0].lon, 24.9384);
+ assert.equal(steps[0].lat, 60.1699);
+ assert.deepEqual(normalizeOsrmSteps(null), []);
+ assert.deepEqual(normalizeOsrmSteps({ legs: [{ steps: null }] }), []);
+});
+
+test('the step list is capped', () => {
+ const steps = Array.from({ length: ROUTE_STEPS_MAX + 50 }, (_, i) => ({
+ maneuver: { type: 'turn', modifier: 'left', location: [i * 0.001, 0.5] }, name: `Street ${i}`, distance: 10, duration: 2,
+ }));
+ assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).length, ROUTE_STEPS_MAX);
+});
+
+test('distance and duration formatting', () => {
+ assert.equal(formatRouteDistance(8), '8 m');
+ assert.equal(formatRouteDistance(846), '850 m');
+ assert.equal(formatRouteDistance(1234), '1.2 km');
+ assert.equal(formatRouteDistance(21479), '21 km');
+ assert.equal(formatRouteDistance(-1), '');
+ assert.equal(formatRouteDuration(40), '40 s');
+ assert.equal(formatRouteDuration(1478), '25 min');
+ assert.equal(formatRouteDuration(3900), '1 h 5 min');
+ assert.equal(formatRouteDuration(7200), '2 h');
+ assert.equal(formatRouteDuration(NaN), '');
+});
diff --git a/src/data/spriteOrder.js b/src/data/spriteOrder.js
index 3b0b4b5..aaabe14 100644
--- a/src/data/spriteOrder.js
+++ b/src/data/spriteOrder.js
@@ -3,6 +3,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv',
'firms',
'bikeshare',
+ 'directions',
'ais',
'military',
'flights',
diff --git a/src/main.js b/src/main.js
index 84fbf12..0f7bc56 100644
--- a/src/main.js
+++ b/src/main.js
@@ -11,6 +11,7 @@ import trafficLayer from './data/traffic.js';
import cctvLayer from './data/cctv.js';
import radioLayer from './data/radio.js';
import bikeshareLayer from './data/bikeshare.js';
+import directionsLayer from './data/directions.js';
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
import militaryInstallationsLayer from './data/militaryInstallations.js';
import militaryAwarenessLayer from './data/militaryAwareness.js';
@@ -217,6 +218,8 @@ async function init() {
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
+ dataManager.register(directionsLayer);
+ directionsLayer.attachDataManager(dataManager);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
diff --git a/vite.config.js b/vite.config.js
index 34dac52..77a8096 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -42,6 +42,7 @@ import {
isOverBudget as isTomTomOverBudget,
} from './src/data/tomtomTiles.js';
import { filterTrailing24h, parseFirmsCsv } from './src/data/firmsCsv.js';
+import { normalizeOsrmSteps } from './src/data/routeSteps.js';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { defineConfig, loadEnv } from 'vite';
@@ -2805,7 +2806,11 @@ function overpassProxy() {
});
// Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike).
- // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...]
+ // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...][&steps=1]
+ // `steps=1` adds turn-by-turn maneuvers (src/data/routeSteps.js). The
+ // upstream is always asked for steps so one cached response serves both
+ // shapes; the annotation engine (no steps) and the Directions layer
+ // (steps) therefore share one upstream call per route.
server.middlewares.use('/api/route', async (req, res) => {
const fail = (msg) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2854,13 +2859,15 @@ function overpassProxy() {
const coords = clean.join(';');
const cacheKey = `${profile}|${coords}`;
const now = Date.now();
+ const wantSteps = url.searchParams.get('steps') === '1';
+ const shapePayload = (payload) => (wantSteps ? payload : { ...payload, steps: undefined });
const cached = _routeCache.get(cacheKey);
if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify(cached.payload));
+ res.end(JSON.stringify(shapePayload(cached.payload)));
return;
}
- const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`;
+ const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=true`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
let osrm;
@@ -2885,11 +2892,12 @@ function overpassProxy() {
distanceM: Math.round(route.distance),
durationS: Math.round(route.duration),
geometry: route.geometry.coordinates,
+ steps: normalizeOsrmSteps(route),
};
_routeCache.set(cacheKey, { payload, cachedAt: now });
if (_routeCache.size > 200) _routeCache.delete(_routeCache.keys().next().value);
res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify(payload));
+ res.end(JSON.stringify(shapePayload(payload)));
} catch (e) {
console.error('[Route Proxy]', e?.message || e);
fail('route proxy error');