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:
parent
759652207f
commit
1abdf90bfb
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 | 🟢 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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]]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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), '');
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
|
|||
'cctv',
|
||||
'firms',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'ais',
|
||||
'military',
|
||||
'flights',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue