feat(directions): keyless A→B directions with turn-by-turn steps and FLY

Adds a Directions data layer: click A and B on the globe for a
street-following drive, walk or cycle route, draped on terrain and 3D tiles,
with one dot per maneuver and a FLY chip that rides the camera along it.
Needs no key, no geocoder and no microphone.

Routing already existed for voice ("route from A to B" → /api/route → OSRM
on the FOSSGIS servers) but was unreachable without an OpenAI key and a
Google geocoder, and returned geometry only. This layer is the keyless front
end to it.

Layer (src/data/directions.js): the row chips are the interface — DRIVE /
WALK / BIKE, SET A / SET B (arm the next globe click; Escape cancels), SWAP,
FLY, CLEAR. Chip writes go through the manager's setLayerParams → setParams
(mode and arm are state; swap/fly/clear are one-shot commands). The route is
a ClassificationType.BOTH ground polyline with the annotation renderer's
flowing-dash material (now exported), so it reads on the keyless terrain
globe and on 3D tiles alike. Clicking a maneuver dot opens a shared-host card
with the instruction, the leg after it and the next instruction. FLY hands
the geometry to flyRoute — the same cinematic as voice fly_route — after an
idempotent initCameraVerbs, which now accepts a missing target getter and
never clears an installed one. A route the router cannot find says so; no
straight line is ever drawn as a route.

Proxy (vite.config.js): /api/route accepts steps=1 and returns turn-by-turn
steps; the upstream is always asked for steps and the full response cached,
so the annotation engine (no steps) and Directions (steps) share one upstream
call per route. src/data/routeSteps.js phrases OSRM maneuver type / modifier
/ exit / road name into one plain-English sentence per decision and folds
"exit roundabout" into the roundabout step.

Manager: adds a public refreshLayerStats() so a layer whose state changes
outside a tick (click placement, routing) can repaint its row.

Credits: OSRM / FOSSGIS routing is now credited in the Data attribution
popover (voice routes used it uncredited).

Verified: 2,719 unit tests, 2,718 pass, 1 skipped (Node 24 allocation gate;
12 new); vite build green; headless Chrome against the dev server over
Helsinki, keyless: A and B placed by click, a 12-step drive, a 28-step walk
after switching mode, the step card on a dot (overlay diagnostics:
directions-step painted), FLY moved the camera, CLEAR reset the row; no
console errors. Docs: README, CHANGELOG, DATA_SOURCES (FOSSGIS routing
terms), docs/CURRENT-STATE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
spcpza 2026-09-09 14:34:59 +07:00
parent 759652207f
commit 1abdf90bfb
17 changed files with 1249 additions and 12 deletions

View File

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

View File

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

View File

@ -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 | 🟢 |

View File

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

View File

@ -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();
}

View File

@ -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();

View File

@ -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 — ' +
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL)',
},
{
key: 'radio-browser',
html:

744
src/data/directions.js Normal file
View File

@ -0,0 +1,744 @@
/**
* @module directions
* @description Keyless AB 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;

View File

@ -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);
});

View File

@ -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' }),

View File

@ -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]]),

View File

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

194
src/data/routeSteps.js Normal file
View File

@ -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`;
}

View File

@ -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), '');
});

View File

@ -3,6 +3,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv',
'firms',
'bikeshare',
'directions',
'ais',
'military',
'flights',

View File

@ -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);

View File

@ -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');