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