diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5df3599..da0aa1fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -355,6 +355,34 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md routes, local setup, fallback behavior and rendering. +### Added + +- **Directions layer** — keyless A→B directions without a geocoder or a + microphone (thanks @spcpza). 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. Below the chips is a compact keyboard-reachable ordered list of the + turns — distance and instruction per step; click one to open that maneuver's + card. FLY rides the shared route-flight cinematic through the same camera + authority voice destinations use, highlights the step it is on as it goes, + and lands when the route under it is replaced or cleared. A route that cannot + be found says so, and one longer than the 200-maneuver cap says it is cut + off; 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`). Steps + are opt-in per request, so callers that do not read them (voice route + annotations, `fly_route`) get exactly the response they got before; identical + requests in flight at the same time share one upstream call; outbound calls + are spaced to the one-per-second rate the routing service's usage policy + states, with a bounded queue behind that gate and an honest 429 past it; the + upstream host is pinned against redirects, and a rate limit from the routing + service is reported as one rather than as a missing route. +- The Data attribution popover now credits OSRM / FOSSGIS routing (used by + voice routes since launch, previously uncredited), with the OpenStreetMap + credit and the "fix the map" link the service's usage policy asks for. + ### Changed - The interface asks Google Fonts for only the icon glyphs it draws, instead of diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d9e14caf..9d546acfe 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -48,6 +48,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 the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + "© OpenStreetMap contributors" + a "fix the map" link — shown in the Data attribution popover | ### Notes on the live sources @@ -68,7 +69,9 @@ How to read this: - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **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. +- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **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. - **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay. diff --git a/README.md b/README.md index fa2945eee..5b0622ba5 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,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 | |-------|--------------|--------|------| @@ -280,6 +280,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi | 📹 **CCTV Mesh** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. 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 33cda95b5..94ea25732 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2370,11 +2370,95 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary 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. Those dots are anchored on the +shared ground floor (`cachedGroundFloor` — the rendered mesh cell on the +photoreal stack, the Re:Earth DEM cell on the keyless terrain stacks) and stay +hidden until their cell answers, so a route through Denver is not marked a +kilometre and a half below the city. 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. Below the chips the +row lists the turns in order — distance and instruction per step, each one a +button a keyboard can reach; clicking one opens that maneuver's card once its +ground cell has resolved, and the step the camera is on is highlighted and +scrolled into view during FLY. SWAP +reverses the endpoints and reroutes; changing the mode reroutes; CLEAR removes +everything. 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. + +**Keys needed: none.** Directions works on a keyless boot, and the layer is off +until the operator turns it on. + +**Context behavior.** Directions is not a Context layer: neither Contacts nor +Space Missions reads it, depends on it, or lists it as a companion. It is +therefore one of the layers an exclusive Context mode switches off on entry +(`_clearLayersOutsideContextMode`) and restores on exit, like every other +non-participating layer — the route and both endpoints are cleared with it, and +placing them again is the way back. Cockpit does not touch the layer either, +but it does own the camera: pressing FLY inside Cockpit is refused with the +shell's "Exit cockpit to fly to a route" toast, because FLY now goes through +the same navigation authority as every other camera destination. + +**Clicks and the camera.** Arming SET A or SET B claims the shared pointer +(`src/data/inputOwnership.js`) as owner `directions` and keeps the lease it is +given; re-arming for the other endpoint reuses that lease rather than asking +for a second claim, and every release names the lease, so a superseded instance +can never free the claim its replacement holds. The claim is held until +the click that uses it has finished being dispatched — every layer binds its +own handler to the same canvas and they all run inside one browser event, so a +claim dropped the instant the endpoint is placed would hand the rest of that +same click to the ambient handlers bound after this one. It is returned on the +next tick after placement, and immediately on cancel, Escape, CLEAR, disable +and destroy; if another tool already holds it nothing is armed and the row says +so. Maneuver-dot selection is an ordinary ambient handler and yields while any +tool holds the pointer. FLY runs through the UI shell's immediate-navigation +facade — the same camera authority validated voice destinations use — and is +handed the shared ground-floor read and corridor warm, so the dolly does not +fly a mountain corridor at sea level. A reroute (SWAP, or a profile change), +CLEAR, disable and destroy stop the flight *this layer started* and only that +one: `flyRoute` returns a motion id and `interruptCameraMotionIfActive` refuses +to stop a motion someone else began. + +`/api/route` (`server/providers/places/routes.js`) asks OSRM for maneuvers only +when the request does (`steps=1`), so a caller that does not read them — the +voice route annotation, `fly_route` — gets the same small response it always +did; a cached response that carries steps still serves those callers their own +shape. Identical requests that are in flight at the same time share one +upstream call. Outbound calls pass a shared gate that spaces them at least one +second apart across every client and profile, which is the rate the routing +service's usage policy states; past a bounded queue the answer is a 429 with a +`Retry-After` rather than a queue that grows until everything times out. The +upstream host is pinned and a redirect is refused rather than followed, and a +rate limit from the routing service is reported as one instead of as "no route +found". `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, and caps one route at +200 maneuvers — a route past that cap is reported as cut off, in the row +summary and as the last line of the list, because the arrival step is among +the ones dropped. + +A step's card opens from its dot on the globe or from its line in the list, and +the list covers every step including departure and arrival, which have no dot. +What a card needs is a resolved ground anchor: a step whose ground cell has not +answered yet opens nothing rather than a card hanging at sea level. The cells +are re-read for up to 90 seconds, which outlasts a cold terrain round trip; +reroute, CLEAR and disable cancel that. + `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/package.json b/package.json index a953f25aa..77e4b33eb 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "./layers/awareness": "./src/layers/awareness/index.js", "./layers/traffic": "./src/layers/traffic/index.js", "./layers/bikeshare": "./src/layers/bikeshare/index.js", + "./layers/directions": "./src/layers/directions/index.js", "./layers/cctv": "./src/layers/cctv/index.js", "./layers/alpr": "./src/layers/alpr/index.js", "./layers/radio": "./src/layers/radio/index.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 64d89d4c0..9c5038f23 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -172,6 +172,7 @@ "src/ui/layerPanel.js", "src/ui/clearLayersControl.js", "scripts/qa-layer-panel.mjs", + "scripts/qa-directions.mjs", "src/loadingFeedback.js", "src/data/installationFeedback.js", "src/ui/location.js", @@ -626,5 +627,11 @@ "src/data/cctvMediaRange.test.mjs", "src/materialSymbolsSubset.test.mjs", "scripts/pinokio-update.mjs", - "src/pinokioUpdatePreview.test.mjs" + "src/pinokioUpdatePreview.test.mjs", + "src/data/routeSteps.js", + "src/data/routeSteps.test.mjs", + "src/data/directions.js", + "src/data/directions.test.mjs", + "src/app/layers/directions.js", + "src/layers/directions/index.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 74ec4b078..d0ce94714 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -65,7 +65,8 @@ "server/providers/common/geo.js", "server/providers/common/http.js", "scripts/google-server-key.mjs", - "src/data/placeProviderPayloads.js" + "src/data/placeProviderPayloads.js", + "src/data/routeSteps.js" ], "external": [] }, @@ -193,7 +194,8 @@ "server/providers/overpass/query.js", "server/providers/overpass/transport.js", "server/providers/places/routes.js", - "src/data/placeProviderPayloads.js" + "src/data/placeProviderPayloads.js", + "src/data/routeSteps.js" ], "external": [] }, @@ -1574,7 +1576,11 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js", "src/annotations/drawMode.js", - "src/annotations/drawTool.js" + "src/annotations/drawTool.js", + "src/data/routeSteps.js", + "src/layers/directions/index.js", + "src/data/directions.js", + "src/app/layers/directions.js" ], "external": [ "@mapbox/vector-tile", @@ -1907,7 +1913,17 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/annotations/worldAnnotationRenderer.js", + "src/cameraVerbs.js", + "src/services/groundFloor.js", + "src/data/routeSteps.js", + "src/layers/directions/index.js", + "src/services/terrainHeights.js", + "src/data/terrainHeights.js", + "src/data/groundFloor.js", + "src/data/directions.js", + "src/app/layers/directions.js" ], "external": [ "cesium", @@ -2075,5 +2091,10 @@ "src/layers/vessels/recordPolicy.js" ], "external": [] + }, + "directions-layer": { + "exports": ["./layers/directions"], + "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], + "external": ["cesium"] } } diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs new file mode 100644 index 000000000..9501690f9 --- /dev/null +++ b/scripts/qa-directions.mjs @@ -0,0 +1,842 @@ +#!/usr/bin/env node +/** + * Browser proof of the Directions layer: real chip clicks, real globe clicks, + * a real route from the real proxy, a real camera flight. + * + * Usage: node scripts/qa-directions.mjs [--url http://localhost:4173] + * [--shots qa-shots/directions] [--label keyed] [--skip denver] + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const arg = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +}; +const BASE = arg('url', 'http://localhost:4173'); +const SHOTS = arg('shots', path.join(ROOT, 'qa-shots', 'directions')); +const LABEL = arg('label', 'keyed'); +const SKIP = new Set((arg('skip', '') || '').split(',').filter(Boolean)); + +await fs.mkdir(SHOTS, { recursive: true }); + +let failures = 0; +const lines = []; +const check = (name, passed, detail = '') => { + const text = `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`; + console.log(text); + lines.push(text); + if (!passed) failures += 1; +}; + +const browser = await puppeteer.launch({ + headless: false, + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1280,860', + ], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1280, height: 860 }); +const pageErrors = []; +page.on('pageerror', (error) => pageErrors.push(error.message)); +const consoleErrors = []; +page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); +}); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const shot = async (name) => { + const file = path.join(SHOTS, name); + await page.screenshot({ path: file, type: 'jpeg', quality: 82 }); + console.log(` shot: ${file}`); +}; + +/** Put the camera over a place, instantly, and let the scene settle. */ +async function lookAt(lat, lon, height) { + await page.evaluate( + (la, lo, h) => { + const viewer = window.__godsEyeView.viewer; + const Cartesian3 = viewer.camera.positionWC.constructor; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: Cartesian3.fromDegrees(lo, la, h), + orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 }, + }); + }, + lat, + lon, + height, + ); +} + +/** Canvas position of a lon/lat on the rendered surface, or null. */ +const screenAt = (lat, lon) => + page.evaluate( + (la, lo) => { + const viewer = window.__godsEyeView.viewer; + const scene = viewer.scene; + const Cartesian3 = viewer.camera.positionWC.constructor; + const Cartographic = viewer.camera.positionCartographic.constructor; + const carto = Cartographic.fromDegrees(lo, la); + let ground = scene.globe?.getHeight?.(carto); + if (!Number.isFinite(ground)) ground = 0; + const world = Cartesian3.fromDegrees(lo, la, ground); + const win = scene.cartesianToCanvasCoordinates(world); + if (!win || !Number.isFinite(win.x)) return null; + const x = Math.round(win.x); + const y = Math.round(win.y); + if (x < 40 || y < 40 || x > 1240 || y > 820) return null; + return { x, y }; + }, + lat, + lon, + ); + +const layerState = () => + page.evaluate(() => { + const gev = window.__godsEyeView; + const manager = gev.dataManager; + const module = manager.layers.get('directions').module; + const row = document.querySelector( + '#data-toggles [data-layer-id="directions"]', + ); + const controls = module.getRowControls(); + const viewer = gev.viewer; + const entities = [...viewer.entities.values]; + const routeEntities = entities.filter( + (entity) => String(entity.id) === 'directions:route', + ); + const listNodes = row + ? [...row.querySelectorAll('.data-row-list-item')] + : []; + return { + stats: module.getStats(), + chips: controls.chips.map((chip) => ({ + id: chip.id, + label: chip.label, + active: !!chip.active, + disabled: !!chip.disabled, + })), + listCount: controls.list?.items?.length || 0, + listText: (controls.list?.items || []).map((item) => item.text), + domList: listNodes.map((node) => node.textContent), + domAllButtons: listNodes.every((node) => node.tagName === 'BUTTON'), + domInOl: + listNodes.length > 0 && + listNodes[0].closest('ol')?.classList.contains('data-row-list'), + domCurrent: listNodes.findIndex((node) => + node.classList.contains('current'), + ), + routeEntities: routeEntities.length, + routePositions: + routeEntities[0]?.polyline?.positions?.getValue?.( + viewer.clock.currentTime, + )?.length || 0, + markerCount: entities.filter((entity) => + String(entity.id).startsWith('directions:marker:'), + ).length, + pointerOwner: window.__gevQa.pointerOwner(), + cameraMotion: window.__gevQa.getActiveCameraMotion(), + }; + }); + +const clickChip = async (chipId) => { + const clicked = await page.evaluate((id) => { + const row = document.querySelector( + '#data-toggles [data-layer-id="directions"]', + ); + const button = row?.querySelector(`button[data-chip-id="${id}"]`); + if (!button || button.disabled) return false; + button.click(); + return true; + }, chipId); + await sleep(150); + return clicked; +}; + +const setMode = async (mode) => { + await clickChip(`mode-${mode}`); +}; + +const waitForRoute = () => + page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('directions') + .module.getStats().count > 0, + { timeout: 40000 }, + ); + +const waitForMode = (word) => + page.waitForFunction( + (w) => + window.__godsEyeView.dataManager.layers + .get('directions') + .module.getStats() + .coverage?.endsWith(w), + { timeout: 40000 }, + word, + ); + +const cameraPose = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionWC; + return { x: c.x, y: c.y, z: c.z }; + }); +const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); + +/** Place A and B by clicking the globe. Returns whether both landed. */ +async function placeEndpoints(a, b) { + await clickChip('set-a'); + const atA = await screenAt(a.lat, a.lon); + if (!atA) return false; + await page.mouse.click(atA.x, atA.y); + await sleep(700); + await clickChip('set-b'); + const atB = await screenAt(b.lat, b.lon); + if (!atB) return false; + await page.mouse.click(atB.x, atB.y); + return true; +} + +try { + await page.goto(`${BASE}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: 90000, + }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: 90000, + }); + // Two module reads the assertions need. Both are the app's OWN module + // instances (Vite serves one copy), so this observes rather than simulates. + await page.evaluate(async () => { + const [own, verbs] = await Promise.all([ + import('/src/data/inputOwnership.js'), + import('/src/cameraVerbs.js'), + ]); + window.__gevQa = { + pointerOwner: own.pointerOwner, + claimPointer: own.claimPointer, + releasePointer: own.releasePointer, + getActiveCameraMotion: verbs.getActiveCameraMotion, + }; + }); + await sleep(3000); + + // ── 1. Open the DATA LAYERS panel, then toggle the row on. ────────────── + const opened = await page.evaluate(() => { + const panel = document.getElementById('data-panel'); + if (!panel) return false; + if (panel.classList.contains('collapsed')) { + panel + .querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]') + ?.click(); + } + return !document + .getElementById('data-panel') + .classList.contains('collapsed'); + }); + await sleep(700); + check('the DATA LAYERS panel opens from its own header control', opened); + const toggled = await page.evaluate(() => { + const row = document.querySelector( + '#data-toggles [data-layer-id="directions"]', + ); + const button = row?.querySelector('.data-toggle-btn'); + if (!button) return false; + button.scrollIntoView({ block: 'center' }); + button.click(); + return true; + }); + await sleep(1200); + check( + 'the Directions row is in the Data Layers panel and toggles on', + toggled, + ); + let state = await layerState(); + check( + 'the row guides the operator before anything is placed', + /SET A/.test(state.stats.coverage || ''), + state.stats.coverage, + ); + + // ── 1b. The routing service's attribution is on screen, with the link its + // usage policy asks for. ──────────────────────────────────────────── + const credit = await page.evaluate(async () => { + const anchor = [...document.querySelectorAll('a')].find((a) => + /data attribution/i.test(a.textContent), + ); + anchor?.click(); + await new Promise((resolve) => setTimeout(resolve, 900)); + const text = document.body.innerText; + const html = document.body.innerHTML; + const open = /Data provided by/i.test(text); + const entry = [...document.querySelectorAll('li')].find((node) => + /OSRM on the FOSSGIS servers/i.test(node.textContent), + ); + entry?.scrollIntoView({ block: 'center' }); + return { + open, + routing: /OSRM on the FOSSGIS servers/i.test(text), + osm: /OpenStreetMap contributors/i.test(text), + fixTheMap: /openstreetmap\.org\/fixthemap/.test(html), + }; + }); + check('the Data attribution popover opens', credit.open); + check('it credits the routing service', credit.routing); + check('it credits OpenStreetMap contributors', credit.osm); + check( + 'it carries the "fix the map" link the usage policy asks for', + credit.fixTheMap, + ); + await shot(`04-${LABEL}-attribution.jpg`); + await page.evaluate(() => { + const close = [...document.querySelectorAll('a, button')].find( + (node) => node.className && /close/i.test(String(node.className)), + ); + close?.click(); + }); + await sleep(500); + + // ── 2. Place A on the Ferry Building, B in Golden Gate Park. ───────────── + const FERRY = { lat: 37.7955, lon: -122.3937 }; + const GGPARK = { lat: 37.7694, lon: -122.4862 }; + await lookAt(37.782, -122.44, 16000); + await sleep(5000); + + await clickChip('set-a'); + state = await layerState(); + check( + 'arming SET A claims the shared pointer', + state.pointerOwner === 'directions', + `owner=${state.pointerOwner}`, + ); + check( + 'the armed chip tells the operator to click the map', + state.chips.find((chip) => chip.id === 'set-a')?.label === 'CLICK MAP', + ); + + let at = await screenAt(FERRY.lat, FERRY.lon); + check('the Ferry Building is on screen', !!at, JSON.stringify(at)); + await page.mouse.click(at.x, at.y); + await sleep(800); + state = await layerState(); + check( + 'placing A releases the pointer claim', + state.pointerOwner === null, + `owner=${state.pointerOwner}`, + ); + check('A is marked on the globe', state.markerCount === 1); + + // A handler bound to the canvas AFTER this layer's own sees the same click. + // That is every ambient selection handler in the app, and it is the one that + // used to place B and deselect whatever was under it in the same gesture. + await page.evaluate(() => { + window.__gevQaLateHandler = []; + const canvas = window.__godsEyeView.viewer.scene.canvas; + canvas.addEventListener('pointerup', () => { + window.__gevQaLateHandler.push(window.__gevQa.pointerOwner()); + }); + }); + + await clickChip('set-b'); + at = await screenAt(GGPARK.lat, GGPARK.lon); + check('Golden Gate Park is on screen', !!at, JSON.stringify(at)); + await page.mouse.click(at.x, at.y); + const lateOwners = await page.evaluate(() => window.__gevQaLateHandler); + check( + 'a handler later in the same click still sees the pointer as taken', + lateOwners.length > 0 && + lateOwners.every((owner) => owner === 'directions'), + `saw ${JSON.stringify(lateOwners)}`, + ); + await waitForRoute(); + await sleep(2000); + state = await layerState(); + check( + 'once that click is over, the pointer claim is returned', + state.pointerOwner === null, + `owner=${state.pointerOwner}`, + ); + check('a route polyline is drawn', state.routeEntities === 1); + check( + 'the route follows streets rather than a straight line', + state.routePositions >= 50, + `${state.routePositions} vertices`, + ); + check( + 'the turn-by-turn list has at least 10 steps', + state.listCount >= 10, + `${state.listCount} steps`, + ); + check( + 'the list renders in the panel as an ordered list of focusable buttons', + state.domList.length === state.listCount && + state.domAllButtons && + state.domInOl, + `${state.domList.length} rendered, ol=${state.domInOl}, buttons=${state.domAllButtons}`, + ); + check( + 'the rendered list is in route order', + state.domList.every((text, index) => text.includes(state.listText[index])), + ); + const driveSummary = state.stats.coverage; + check( + 'the row summarises distance, time and mode', + /·.*·\s*Drive$/.test(driveSummary || ''), + driveSummary, + ); + const focused = await page.evaluate(() => { + const row = document.querySelector( + '#data-toggles [data-layer-id="directions"]', + ); + const items = row.querySelectorAll('.data-row-list-item'); + items[2]?.focus(); + const active = document.activeElement === items[2]; + items[2]?.click(); + return { active, tabIndex: items[2]?.tabIndex }; + }); + await sleep(600); + const afterListClick = await layerState(); + check( + 'a list row takes keyboard focus and selects its maneuver', + focused.active && afterListClick.stats.count > 0, + `focused=${focused.active}`, + ); + await shot(`01-${LABEL}-sf-route-and-list.jpg`); + + // ── 3. Switch profile; the route changes. ──────────────────────────────── + await setMode('foot'); + await waitForMode('Walk'); + const walk = await layerState(); + check( + 'WALK reroutes and the summary follows', + walk.stats.coverage !== driveSummary, + walk.stats.coverage, + ); + check( + 'the walking route is its own route', + walk.listCount !== state.listCount || walk.stats.coverage !== driveSummary, + `drive ${state.listCount} steps vs walk ${walk.listCount}`, + ); + await setMode('bike'); + await waitForMode('Bike'); + const bike = await layerState(); + check( + 'BIKE reroutes as well', + /Bike$/.test(bike.stats.coverage), + bike.stats.coverage, + ); + await setMode('car'); + await waitForMode('Drive'); + + // ── 4. Swap endpoints. ────────────────────────────────────────────────── + const beforeSwap = await layerState(); + await clickChip('swap'); + await sleep(3500); + const afterSwap = await layerState(); + check( + 'SWAP reverses the route', + afterSwap.routeEntities === 1 && + afterSwap.listText[0] !== beforeSwap.listText[0], + `"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`, + ); + await clickChip('swap'); + await sleep(3500); + + // ── 5. Three rapid reroutes leave exactly one route. ───────────────────── + await page.evaluate(() => { + const manager = window.__godsEyeView.dataManager; + manager.setLayerParams('directions', { mode: 'foot' }, { origin: 'user' }); + manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' }); + manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' }); + }); + await sleep(6000); + const rapid = await layerState(); + check( + 'three rapid reroutes leave exactly one route', + rapid.routeEntities === 1, + `${rapid.routeEntities} route entities`, + ); + check( + 'the surviving route is the last one asked for', + /Drive$/.test(rapid.stats.coverage || ''), + rapid.stats.coverage, + ); + + // ── 6. What the row says for each shape the proxy can answer with. + // + // These stub the proxy's RESPONSE, so they test this layer's handling of it + // and nothing about the proxy itself. What the proxy actually answers for a + // routing-service rate limit, a redirect, an over-long route and a full + // outbound queue is covered where it can be observed — the provider tests in + // src/tooling/placeProviders.test.mjs. The bodies below are the ones those + // tests pin, so the two halves meet. ───────────────────────────────────── + const cdp = await page.target().createCDPSession(); + let stub = null; + cdp.on('Fetch.requestPaused', async ({ requestId }) => { + if (!stub) { + await cdp.send('Fetch.continueRequest', { requestId }).catch(() => {}); + return; + } + if (stub.hold) return; // never answer: the client's own timeout must fire + await cdp + .send('Fetch.fulfillRequest', { + requestId, + responseCode: stub.status, + responseHeaders: [ + { name: 'Content-Type', value: 'application/json' }, + ...(stub.status === 429 ? [{ name: 'Retry-After', value: '5' }] : []), + ], + body: Buffer.from(stub.body).toString('base64'), + }) + .catch(() => {}); + }); + await cdp.send('Fetch.enable', { + patterns: [{ urlPattern: '*/api/route*', requestStage: 'Request' }], + }); + + const reroute = async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.setLayerParams( + 'directions', + { mode: 'foot' }, + { origin: 'user' }, + ), + ); + }; + const failCases = [ + { + name: 'no route found', + stub: { status: 200, body: '{"ok":false,"error":"no route found"}' }, + wait: 3500, + expect: /No route found between A and B/, + }, + { + // The exact body the proxy sends when the routing service rate-limits + // US (pinned by the provider test of the same name). + name: 'the routing service rate-limited us (429)', + stub: { + status: 429, + body: '{"ok":false,"error":"routing service is rate limited"}', + }, + wait: 3500, + expect: /routing service is rate limited/i, + }, + { + // And when our own outbound queue is full. + name: 'our own outbound queue is full (429)', + stub: { + status: 429, + body: '{"ok":false,"error":"routing busy — too many routes at once"}', + }, + wait: 3500, + expect: /routing busy/i, + }, + { + name: 'upstream never answers (timeout)', + stub: { hold: true }, + wait: 19000, + expect: /Routing timed out/, + }, + ]; + for (const failCase of failCases) { + stub = failCase.stub; + await reroute(); + await sleep(failCase.wait); + const failed = await layerState(); + check( + `the row reports "${failCase.name}" honestly`, + failCase.expect.test(failed.stats.error || ''), + failed.stats.error, + ); + check( + `"${failCase.name}" leaves no straight line drawn as a route`, + failed.routeEntities === 0, + ); + // Back to a real route before the next case. + stub = null; + await page.evaluate(() => + window.__godsEyeView.dataManager.setLayerParams( + 'directions', + { mode: 'car' }, + { origin: 'user' }, + ), + ); + await waitForRoute(); + await sleep(1200); + } + await cdp.send('Fetch.disable').catch(() => {}); + + // ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ───── + const beforeFly = await cameraPose(); + check('the FLY chip is offered once a route exists', await clickChip('fly')); + await sleep(3000); + const flying = await layerState(); + const midFly = await cameraPose(); + check( + 'FLY starts a route flight', + flying.cameraMotion?.kind === 'route', + JSON.stringify(flying.cameraMotion), + ); + check( + 'the camera actually moved', + poseDelta(beforeFly, midFly) > 50, + `${Math.round(poseDelta(beforeFly, midFly))} m`, + ); + check( + 'the list highlights the step being flown', + flying.domCurrent >= 0, + `dom index ${flying.domCurrent}`, + ); + await shot(`02-${LABEL}-mid-flight.jpg`); + + // A reroute replaces the route the dolly is flying; the dolly has to land + // first rather than keep flying a route that no longer exists. + await setMode('foot'); + await sleep(1200); + const afterReroute = await layerState(); + check( + 'changing profile mid-flight lands the flight it replaced', + afterReroute.cameraMotion === null, + JSON.stringify(afterReroute.cameraMotion), + ); + await waitForMode('Walk'); + await setMode('car'); + await waitForMode('Drive'); + await sleep(800); + check('FLY is offered again after the reroute', await clickChip('fly')); + await sleep(2000); + + await clickChip('clear'); + await sleep(500); + const stopped = await cameraPose(); + await sleep(3000); + const later = await cameraPose(); + const afterClear = await layerState(); + check( + 'CLEAR mid-flight stops the flight it started', + afterClear.cameraMotion === null, + JSON.stringify(afterClear.cameraMotion), + ); + check( + 'no later frame moves the camera again', + poseDelta(stopped, later) < 5, + `${poseDelta(stopped, later).toFixed(2)} m drift`, + ); + check( + 'CLEAR removes the route and both markers', + afterClear.routeEntities === 0 && afterClear.markerCount === 0, + ); + check( + 'CLEAR leaves the pointer free', + afterClear.pointerOwner === null, + `owner=${afterClear.pointerOwner}`, + ); + + // ── 8. A tool holding the pointer blocks placement, and says so. ───────── + await page.evaluate(() => { + // A claim answers with a lease; the release takes that lease back. + window.__gevQaDrawLease = window.__gevQa.claimPointer('draw'); + }); + await clickChip('set-a'); + const blocked = await layerState(); + check( + "another tool's pointer claim is never stolen", + blocked.pointerOwner === 'draw', + `owner=${blocked.pointerOwner}`, + ); + check( + 'nothing is armed, and the row says why the click would do nothing', + blocked.chips.find((chip) => chip.id === 'set-a')?.active === false && + /Another map tool/.test(blocked.stats.error || ''), + blocked.stats.error, + ); + // The row is not where the operator is looking after pressing a chip, so the + // refusal also has to reach the app's toast (owner field test). + const toast = await page.evaluate(() => { + const node = document.getElementById('toast'); + return { + text: node?.textContent || '', + visible: !!node?.classList.contains('visible'), + }; + }); + check( + 'the refusal reaches the app toast, naming the tool and how to leave it', + /Draw is active/.test(toast.text) && /then set A/.test(toast.text), + JSON.stringify(toast.text), + ); + check('and that toast is actually on screen', toast.visible); + await shot(`05-${LABEL}-pointer-blocked-toast.jpg`); + const released = await page.evaluate(() => + window.__gevQa.releasePointer(window.__gevQaDrawLease), + ); + check('and it gets the pointer back when it lets go', released); + + // ── 9. Denver: maneuver dots sit on the ground, not at sea level. ──────── + if (!SKIP.has('denver')) { + await lookAt(39.744, -104.999, 7000); + await sleep(6000); + const placed = await placeEndpoints( + { lat: 39.7392, lon: -104.9903 }, + { lat: 39.7487, lon: -105.0077 }, + ); + check('both Denver endpoints are on screen', placed); + if (placed) { + await waitForRoute(); + // Let the shared ground-floor cells warm and the dots re-anchor. + await sleep(9000); + const denver = await page.evaluate(() => { + const viewer = window.__godsEyeView.viewer; + const Cartographic = viewer.camera.positionCartographic.constructor; + const scene = viewer.scene; + let collection = null; + for (let i = 0; i < scene.primitives.length; i += 1) { + const primitive = scene.primitives.get(i); + if ( + primitive && + typeof primitive.get === 'function' && + primitive.length > 0 && + String(primitive.get(0)?.id || '').startsWith('directions:step:') + ) { + collection = primitive; + break; + } + } + if (!collection) return { dots: 0, shown: 0, heights: [] }; + const heights = []; + for (let i = 0; i < collection.length; i += 1) { + const point = collection.get(i); + if (!point.show) continue; + heights.push(Cartographic.fromCartesian(point.position).height); + } + return { dots: collection.length, shown: heights.length, heights }; + }); + check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`); + check( + 'every Denver dot resolved its ground cell — none left hidden', + denver.dots > 0 && denver.shown === denver.dots, + `${denver.shown} of ${denver.dots} shown`, + ); + // Denver's ground is ~1.58-1.65 km ellipsoidal (~1.6 km orthometric plus + // the local geoid separation). A dot left at a few metres would fail this + // by a kilometre and a half. + check( + 'every Denver dot is anchored on the ground, not at sea level', + denver.dots > 0 && + denver.heights.length === denver.dots && + denver.heights.every((h) => h > 1400 && h < 1900), + `heights ${denver.heights.map((h) => Math.round(h)).join(', ')}`, + ); + await shot(`03-${LABEL}-denver-markers.jpg`); + } + } + + // ── 10. Disable: nothing is left behind. ──────────────────────────────── + const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => { + const entities = [...window.__godsEyeView.viewer.entities.values]; + return { + beforeDisable: entities.length, + directionsBeforeDisable: entities.filter((entity) => + String(entity.id).startsWith('directions:'), + ).length, + }; + }); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('directions', false, { + origin: 'user', + }), + ); + await sleep(1500); + const off = await page.evaluate(() => { + const gev = window.__godsEyeView; + const viewer = gev.viewer; + const left = [...viewer.entities.values].filter((entity) => + String(entity.id).startsWith('directions:'), + ); + let shownDots = 0; + for (let i = 0; i < viewer.scene.primitives.length; i += 1) { + const primitive = viewer.scene.primitives.get(i); + if ( + primitive && + typeof primitive.get === 'function' && + primitive.length > 0 && + String(primitive.get(0)?.id || '').startsWith('directions:step:') + ) { + shownDots = primitive.show ? primitive.length : 0; + } + } + const row = document.querySelector( + '#data-toggles [data-layer-id="directions"]', + ); + return { + entities: left.length, + shownDots, + controlsHidden: + row?.querySelector('.data-toggle-controls')?.hidden === true, + listHidden: row?.querySelector('.data-row-list')?.hidden === true, + pointerOwner: window.__gevQa.pointerOwner(), + motion: window.__gevQa.getActiveCameraMotion(), + totalEntities: viewer.entities.values.length, + }; + }); + check( + 'disabling removes every Directions entity', + off.entities === 0, + `${off.entities} left`, + ); + check('disabling hides the maneuver dots', off.shownDots === 0); + check( + 'disabling hides the chips and the turn list', + off.controlsHidden && off.listHidden, + `controls=${off.controlsHidden} list=${off.listHidden}`, + ); + check( + 'disabling leaves the pointer free', + off.pointerOwner === null, + `owner=${off.pointerOwner}`, + ); + check('disabling leaves no camera motion running', off.motion === null); + check( + 'exactly the Directions entities went away, and nothing else did', + off.totalEntities === beforeDisable - directionsBeforeDisable, + `${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`, + ); + + check( + 'no uncaught page errors', + pageErrors.length === 0, + pageErrors.join(' | '), + ); + const realConsoleErrors = consoleErrors.filter( + (text) => + !/Failed to load resource|net::ERR|favicon|429|ERR_ABORTED|status of 4/i.test( + text, + ), + ); + check( + 'no unexpected console errors', + realConsoleErrors.length === 0, + realConsoleErrors.slice(0, 3).join(' | '), + ); +} catch (error) { + check('harness completed', false, error?.stack || String(error)); + await shot(`99-${LABEL}-failure.jpg`).catch(() => {}); +} finally { + await browser.close(); + await fs.writeFile( + path.join(SHOTS, `qa-directions-${LABEL}.log`), + `${lines.join('\n')}\n${failures} failure(s)\n`, + ); + console.log(`\n${failures} failure(s)`); + process.exit(failures ? 1 : 0); +} diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index b566db33a..42e24ad32 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -1,17 +1,102 @@ import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; import { haversineKm } from '../common/geo.js'; import { readResponseTextCapped } from '../common/http.js'; +import { normalizeOsrmSteps } from '../../../src/data/routeSteps.js'; import { normalizeRouteProfile, projectRouteResult, } from '../../../src/data/placeProviderPayloads.js'; -/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ +/** + * OSM routing (FOSSGIS OSRM) cache: profile|coords -> + * { payload, hasSteps, cachedAt }. `hasSteps` records whether the upstream + * call behind this entry asked for maneuvers, because an entry without them + * cannot answer a request that wants them. + */ const ROUTE_CACHE_MS = 600000; +/** + * Upstream calls currently in flight, keyed by endpoint + route + step shape. + * Two identical requests that arrive before the first one answers (three rapid + * reroutes of the same A→B, a second browser tab) await the SAME upstream + * fetch. The FOSSGIS servers ask for no heavy use; the cheapest way to honour + * that is not to make the call twice. Module-level, because what it is + * deduplicating is this process's outbound traffic — the endpoint is part of + * the key, so two installations pointed at different services never share one. + * @type {Map>} + */ +const _routeInflight = new Map(); + /** Hard cap on the OSRM route response we will buffer. */ const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB +/** + * Minimum gap between two OUTBOUND route requests, across every client and + * every profile. The FOSSGIS servers publish "one request per second max", and + * the per-client rate limiter below cannot honour that on its own: two + * different clients, or one client switching DRIVE to WALK, are two different + * cache keys and left 48 ms apart upstream. This is the gate that makes the + * app's outbound rate what the policy says it may be. + */ +export const ROUTE_UPSTREAM_MIN_INTERVAL_MS = 1000; + +/** + * How many requests may be waiting for that gate at once. Past this the answer + * is an honest 429 rather than a queue that grows until everything times out. + */ +export const ROUTE_UPSTREAM_QUEUE_MAX = 8; + +/** The interval actually in force; only a test ever shortens it. */ +let _upstreamIntervalMs = ROUTE_UPSTREAM_MIN_INTERVAL_MS; + +/** Earliest wall-clock time the next outbound request may leave. */ +let _nextUpstreamAt = 0; +/** Requests currently holding or waiting for the outbound slot. */ +let _upstreamQueueDepth = 0; + +/** Thrown when the outbound queue is full; answered as a 429. */ +class RouteBusyError extends Error { + constructor(retryAfterMs) { + super('routing busy'); + this.name = 'RouteBusyError'; + this.retryAfterMs = retryAfterMs; + } +} + +/** Thrown when the routing service itself rate-limited us. */ +class UpstreamRateLimitError extends Error { + constructor(retryAfterSec) { + super('routing service is rate limited'); + this.name = 'UpstreamRateLimitError'; + this.retryAfterSec = retryAfterSec; + } +} + +/** + * Run one outbound request no sooner than the shared gate allows. + * @template T + * @param {() => Promise} run The request. + * @returns {Promise} + */ +async function throughUpstreamGate(run) { + if (_upstreamQueueDepth >= ROUTE_UPSTREAM_QUEUE_MAX) { + throw new RouteBusyError( + Math.max(0, _nextUpstreamAt - Date.now()) + _upstreamIntervalMs, + ); + } + _upstreamQueueDepth += 1; + try { + const now = Date.now(); + const slot = Math.max(now, _nextUpstreamAt); + _nextUpstreamAt = slot + _upstreamIntervalMs; + const wait = slot - now; + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + return await run(); + } finally { + _upstreamQueueDepth -= 1; + } +} + /** Reject routes whose straight-line spans are obviously abusive (km). */ const ROUTE_MAX_LEG_KM = 600; @@ -23,6 +108,113 @@ const _routeRateLimiter = makeRateLimiter({ globalMax: 200, }); +/** Test seam: forget the outbound state this module keeps between requests. */ +export function _resetRouteUpstreamForTest() { + _routeInflight.clear(); + _nextUpstreamAt = 0; + _upstreamQueueDepth = 0; + _upstreamIntervalMs = ROUTE_UPSTREAM_MIN_INTERVAL_MS; +} + +/** + * Test seam: shorten the outbound gate so a queue-overflow test does not have + * to wait out the real one-per-second policy. The policy value itself is + * asserted separately. + * @param {number} ms + */ +export function _setRouteUpstreamIntervalForTest(ms) { + _upstreamIntervalMs = + Number.isFinite(ms) && ms >= 0 ? ms : ROUTE_UPSTREAM_MIN_INTERVAL_MS; +} + +/** Test seam: how many upstream calls are coalescing right now. */ +export function _routeInflightCountForTest() { + return _routeInflight.size; +} + +/** Release a response body we are not going to read. */ +async function cancelBody(response) { + try { + await response.body?.cancel(); + } catch { + /* already closed */ + } +} + +/** + * Fetch one route from the routing service and project it. + * @param {object} request + * @param {string} request.profile foot | car | bike + * @param {string} request.osrmProfile Upstream profile name. + * @param {string} request.base Endpoint base for this profile. + * @param {string} request.coords `lon,lat;lon,lat[;...]` + * @param {boolean} request.withSteps Ask upstream for maneuvers. + * @param {Function} request.fetchImpl Injected request function. + * @returns {Promise<{payload: object|null, error: string|null}>} + */ +async function fetchRoute({ + profile, + osrmProfile, + base, + coords, + withSteps, + fetchImpl, +}) { + // `steps` is opt-in per request. Asking for maneuvers on every call made the + // response several times larger for the callers that never read them (the + // voice route annotation, fly_route), on someone else's bandwidth. + const upstream = + `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + let osrm; + try { + const upstreamRes = await throughUpstreamGate(() => + fetchImpl(upstream, { + signal: controller.signal, + // The endpoint is configured above; a redirect is the one way out of + // it, so it is refused rather than followed. + redirect: 'error', + headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, + }), + ); + if (upstreamRes.status === 429) { + await cancelBody(upstreamRes); + const retryAfter = Number(upstreamRes.headers.get('retry-after')); + throw new UpstreamRateLimitError( + Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5, + ); + } + if (!upstreamRes.ok) { + await cancelBody(upstreamRes); + return { payload: null, error: 'no route found' }; + } + const ctype = upstreamRes.headers.get('content-type') || ''; + if (!ctype.includes('json')) { + await cancelBody(upstreamRes); + return { payload: null, error: 'no route found' }; + } + const text = await readResponseTextCapped( + upstreamRes, + ROUTE_MAX_RESPONSE_BYTES, + ); + osrm = JSON.parse(text); + } finally { + clearTimeout(timer); + } + const route = osrm?.routes?.[0]; + if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) + return { payload: null, error: 'no route found' }; + const payload = projectRouteResult(route, profile); + if (withSteps) { + const { steps, truncated } = normalizeOsrmSteps(route); + payload.steps = steps; + if (truncated) payload.stepsTruncated = true; + } + return { payload, error: null }; +} + export function installRouteMiddleware( middlewares, { endpoints = {}, fetchImpl = (...args) => fetch(...args) } = {}, @@ -30,19 +222,25 @@ export function installRouteMiddleware( const _routeCache = new Map(); // 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) and is the + // only shape that asks the upstream for them. A response WITHOUT steps is + // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { const fail = (msg) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: msg })); }; + const rateLimited = (msg, retryAfterSec) => { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': String(Math.max(1, Math.round(retryAfterSec || 5))), + }); + res.end(JSON.stringify({ ok: false, error: msg })); + }; try { if (!_routeRateLimiter(clientKey(req))) { - res.writeHead(429, { - 'Content-Type': 'application/json', - 'Retry-After': '5', - }); - res.end(JSON.stringify({ ok: false, error: 'rate limited' })); + rateLimited('rate limited', 5); return; } const url = new URL(req.url, 'http://localhost'); @@ -92,46 +290,78 @@ export function installRouteMiddleware( if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); const coords = clean.join(';'); const cacheKey = `${profile}|${coords}`; - const now = Date.now(); - 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)); - return; - } const base = endpoints[profile] || `https://routing.openstreetmap.de/routed-${profile}`; - const upstream = `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 12000); - let osrm; - try { - const upstreamRes = await fetchImpl(upstream, { - signal: controller.signal, - redirect: 'error', - headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, - }); - if (!upstreamRes.ok) return fail('no route found'); - const ctype = upstreamRes.headers.get('content-type') || ''; - if (!ctype.includes('json')) return fail('no route found'); - const text = await readResponseTextCapped( - upstreamRes, - ROUTE_MAX_RESPONSE_BYTES, - ); - osrm = JSON.parse(text); - } finally { - clearTimeout(timer); + const now = Date.now(); + const wantSteps = url.searchParams.get('steps') === '1'; + // A caller that did not ask for maneuvers never sees them, even when the + // cached entry carries them for someone else. + const shapePayload = (payload) => + wantSteps ? payload : { ...payload, steps: undefined }; + const cached = _routeCache.get(cacheKey); + if ( + cached && + now - cached.cachedAt <= ROUTE_CACHE_MS && + (!wantSteps || cached.hasSteps) + ) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(shapePayload(cached.payload))); + return; } - const route = osrm?.routes?.[0]; - if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) - return fail('no route found'); - const payload = projectRouteResult(route, profile); - _routeCache.set(cacheKey, { payload, cachedAt: now }); + const inflightKey = `${base}|${cacheKey}|${wantSteps ? 's' : 'n'}`; + // A stepless request can also ride a stepful call already in flight — + // it just drops the maneuvers on the way out. + let pending = + _routeInflight.get(inflightKey) || + (wantSteps ? null : _routeInflight.get(`${base}|${cacheKey}|s`)); + if (!pending) { + pending = fetchRoute({ + profile, + osrmProfile, + base, + coords, + withSteps: wantSteps, + fetchImpl, + }); + _routeInflight.set(inflightKey, pending); + const settle = () => { + if (_routeInflight.get(inflightKey) === pending) + _routeInflight.delete(inflightKey); + }; + pending.then(settle, settle); + } + let payload; + let error; + try { + ({ payload, error } = await pending); + } catch (upstreamError) { + if (upstreamError instanceof RouteBusyError) { + return rateLimited( + 'routing busy — too many routes at once', + Math.ceil(upstreamError.retryAfterMs / 1000), + ); + } + if (upstreamError instanceof UpstreamRateLimitError) { + // Reporting this as "no route found" would blame the map for + // something the routing service said about us. + return rateLimited( + 'routing service is rate limited', + upstreamError.retryAfterSec, + ); + } + throw upstreamError; + } + if (error || !payload) return fail(error || 'no route found'); + _routeCache.set(cacheKey, { + payload, + hasSteps: Array.isArray(payload.steps), + cachedAt: Date.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'); diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index 64a7df241..ef71ce342 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -336,7 +336,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; @@ -352,7 +352,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/app/constructCatalog.js b/src/app/constructCatalog.js index 184993db8..cbc848bd5 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -8,6 +8,7 @@ import { createApplicationCctv } from './layers/cctv.js'; import { createApplicationRadio } from './layers/radio.js'; import { createApplicationTraffic } from './layers/traffic.js'; import { createApplicationBikeshare } from './layers/bikeshare.js'; +import { createApplicationDirections } from './layers/directions.js'; import { createApplicationInstallations } from './layers/militaryInstallations.js'; import { createApplicationSatellites } from './layers/satellites.js'; import { createApplicationLaunches } from './layers/rocketLaunches.js'; @@ -109,6 +110,7 @@ export function createApplicationCatalog({ createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), createApplicationBikeshare({ source: sources.bikeshare }), + createApplicationDirections(), vessels, installations, createApplicationAwareness({ diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 57da9e219..d25ad45ee 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -38,7 +38,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 17); + assert.equal(first.layers.length, 18); assert.deepEqual( first.layers.map(({ id }) => id), second.layers.map(({ id }) => id), diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js new file mode 100644 index 000000000..1e535fc88 --- /dev/null +++ b/src/app/layers/directions.js @@ -0,0 +1,12 @@ +import { createApplicationDirectionsLayer } from '../../data/directions.js'; + +/** + * Construct one Directions layer using the application scene owners. + * + * It takes no data source: a route is fetched from `/api/route` when the + * operator places both endpoints, so there is nothing to supply up front. + * @returns {object} A fresh layer instance for this catalog. + */ +export function createApplicationDirections() { + return createApplicationDirectionsLayer(); +} diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 700ef1cdf..dc5f174de 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -805,9 +805,49 @@ export function advanceRouteFlight(state, dt) { let _viewer = null; let _getTarget = null; -let _active = null; // { kind, motion, direction, speed, mode, ...state } +let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; let _inputRemovers = []; +/** Monotonic id for the motion currently running; 0 means "no motion". */ +let _motionSeq = 0; + +/** + * Install a motion and stamp it with a fresh id. The id is what lets a caller + * that started a flight stop THAT flight later without stopping whatever has + * replaced it in the meantime. + * @param {object|null} state Motion state, or null to clear. + * @returns {object|null} The installed state. + */ +function setActiveMotion(state) { + if (state) { + _motionSeq += 1; + state.motionId = _motionSeq; + } + _active = state; + return state; +} + +/** + * @returns {number} Id of the running motion, or 0 when the camera is idle. + */ +export function activeCameraMotionId() { + return _active?.motionId || 0; +} + +/** + * Stop a motion only while it is still the one running. A feature that owns a + * flight calls this on clear/teardown: if the user (or voice, or a tracked + * entity) has since taken the camera, the newer motion is left alone. + * @param {number} motionId Id returned when the motion started. + * @param {string} [reason] Diagnostic label. + * @returns {{wasActive: boolean, reason: string, leveled: boolean}} + */ +export function interruptCameraMotionIfActive(motionId, reason = 'owner-cancel') { + if (!motionId || _active?.motionId !== motionId) { + return { wasActive: false, reason, leveled: false }; + } + return interruptCameraMotion(reason); +} function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } catch { /* teardown race */ } @@ -856,7 +896,10 @@ export function interruptCameraMotion(reason = 'interrupt') { /** Diagnostics / harness hook. */ export function getActiveCameraMotion() { if (!_active) return null; - const info = { kind: _active.kind, mode: _active.mode, speed: _active.speed }; + const info = { + kind: _active.kind, mode: _active.mode, speed: _active.speed, + motionId: _active.motionId || 0, + }; if (_active.kind === 'route') { info.progress = _active.u; info.bankDeg = _active.bankDeg; @@ -968,11 +1011,26 @@ function onTick() { } } -/** Wire the module to the viewer once (idempotent). */ -export function initCameraVerbs(viewer, getViewTargetCartesian) { - if (_viewer === viewer) return; +/** + * 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, FOR THE SAME VIEWER, never clears an + * installed getter. + * + * A getter closes over the viewer it was built for. When the viewer changes — + * a new application lifetime — the previous getter is dropped even if this + * call brings none, so a callback from the old viewer can never answer for the + * new one. + */ +export function initCameraVerbs(viewer, getViewTargetCartesian = null) { + const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; + if (_viewer === viewer) { + if (nextTarget) _getTarget = nextTarget; + return; + } + _getTarget = nextTarget; _viewer = viewer; - _getTarget = getViewTargetCartesian; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); for (const rm of _inputRemovers) rm(); @@ -1045,7 +1103,7 @@ export function moveCamera(args = {}, runNavigation = null) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm // and capture at the destination; a target existing right now is // meaningless (the globe is always under the crosshair). - _active = { kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }; + setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }); holdContinuousRender('camera-verb'); return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' }; } @@ -1053,7 +1111,7 @@ export function moveCamera(args = {}, runNavigation = null) { if (!target) { // Mid-flight chain ("fly to X and orbit it"): ARM the orbit — the tick // loop keeps trying for a ground target as the flight settles. - _active = { kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }; + setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true }); holdContinuousRender('camera-verb'); return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' }; } @@ -1068,7 +1126,7 @@ export function moveCamera(args = {}, runNavigation = null) { state.hpr = new Cesium.HeadingPitchRange(cam.heading, pitch, range); state.hprStartHeading = cam.heading; } - _active = state; + setActiveMotion(state); // Verb motion drives the camera per clock tick — hold until interrupted; // interruptCameraMotion is the single release path. (perf wave 2) holdContinuousRender('camera-verb'); @@ -1136,7 +1194,7 @@ export function flyRoute(annoList, args = {}, floorFn = null, runNavigation = nu } const start = () => { interruptCameraMotion('replaced'); - _active = createRouteFlight({ + setActiveMotion(createRouteFlight({ pts, cumM, speed, @@ -1145,12 +1203,15 @@ export function flyRoute(annoList, args = {}, floorFn = null, runNavigation = nu probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells), cameraHeightM: _viewer?.camera?.positionCartographic?.height, reducedMotion: prefersReducedMotion(), - }); + })); holdContinuousRender('camera-verb'); return { ok: true, action: 'fly_route', label: route.label || null, speed, distanceM: Math.round(_active.totalM), durationS: Math.round(_active.durationS), waypoints: pts.length, + // The caller keeps this so it can stop ITS flight later without stopping + // whatever has replaced it (see interruptCameraMotionIfActive). + motionId: _active.motionId, }; }; return typeof runNavigation === 'function' ? runNavigation(start) : start(); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 9642c95b5..c946a21fc 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -173,6 +173,16 @@ export const DATA_CREDITS = [ key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', }, + { + key: 'osrm-routing', + // The service asks for its attribution to carry a "fix the map" link, so + // a reader who spots a wrong turn can go and correct the data it came from. + html: + 'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' + + 'routing.openstreetmap.de · ' + + '© OpenStreetMap contributors (ODbL) · ' + + 'fix the map', + }, { key: 'radio-browser', html: diff --git a/src/data/directions.js b/src/data/directions.js new file mode 100644 index 000000000..8770f44b3 --- /dev/null +++ b/src/data/directions.js @@ -0,0 +1,69 @@ +/** + * @module directions + * @description Service bundle for the Directions layer. + * + * The layer itself lives in `src/layers/directions/`, which imports nothing + * but Cesium and the pure step formatter. This module is the one place that + * names the application's shared services — the render governor, sprite order, + * the pick registry, the world overlay, the annotation material, the camera + * verbs, the shared ground floor and the pointer arbiter — and it re-exports + * the layer's pure helpers for the tests and the share-link code. + */ + +import * as render from '../renderGovernor.js'; +import * as sprites from './spriteOrder.js'; +import * as picking from './pickRegistry.js'; +import * as scenePick from './scenePick.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as annotations from '../annotations/worldAnnotationRenderer.js'; +import * as camera from '../cameraVerbs.js'; +import * as ground from './groundFloor.js'; +import * as input from './inputOwnership.js'; +import { createDirectionsLayer } from '../layers/directions/index.js'; + +/** The shared scene owners every Directions instance runs on. */ +export const directionsServices = Object.freeze({ + render, + sprites, + picking, + scenePick, + overlays, + annotations, + camera, + ground, + input, +}); + +/** Construct one Directions layer over the application scene owners. */ +export function createApplicationDirectionsLayer() { + return createDirectionsLayer({ services: directionsServices }); +} + +export { + DEFAULT_DIRECTIONS_MODE, + DIRECTIONS_MODES, + DIRECTIONS_POINTER_OWNER, + DIRECTIONS_ROUTE_COLOR, + DIRECTIONS_STEP_OVERLAY_SOURCE_ID, + DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS, + FLIGHT_PROGRESS_MS, + POINTER_TOOL_EXITS, + STEP_ANCHOR_DEADLINE_MS, + STEP_ANCHOR_FAST_ATTEMPTS, + STEP_ANCHOR_RETRY_MS, + STEP_ANCHOR_SLOW_RETRY_MS, + createDirectionsLayer, + createDirectionsStepOverlayEntry, + directionsRequestUrl, + directionsRowControls, + directionsStats, + directionsStepCopy, + directionsStepList, + normalizeDirectionsParams, + normalizeRoutePayload, + pointerBlockedMessage, + stepAnchorDelayMs, + stepIndexAtDistance, + stepMarkerHeightM, + stepMarkerIndices, +} from '../layers/directions/index.js'; diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs new file mode 100644 index 000000000..9dc015f9f --- /dev/null +++ b/src/data/directions.test.mjs @@ -0,0 +1,775 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + DEFAULT_DIRECTIONS_MODE, + DIRECTIONS_MODES, + DIRECTIONS_POINTER_OWNER, + DIRECTIONS_ROUTE_COLOR, + createDirectionsStepOverlayEntry, + directionsRequestUrl, + directionsRowControls, + directionsStats, + directionsStepCopy, + directionsStepList, + normalizeDirectionsParams, + normalizeRoutePayload, + stepAnchorDelayMs, + stepIndexAtDistance, + stepMarkerHeightM, + stepMarkerIndices, + pointerBlockedMessage, + POINTER_TOOL_EXITS, + STEP_ANCHOR_DEADLINE_MS, + STEP_ANCHOR_RETRY_MS, + STEP_ANCHOR_SLOW_RETRY_MS, + STEP_ANCHOR_FAST_ATTEMPTS, + createApplicationDirectionsLayer, +} from './directions.js'; +import { GROUND_FLOOR_LIFT_M } from './groundFloor.js'; +import { + claimPointer, + isPointerFree, + pointerOwner, + releasePointer, + resetPointerOwnership, +} from './inputOwnership.js'; + +/** Take the pointer as some other tool, and hand back its release. */ +function claimAs(owner) { + const lease = claimPointer(owner); + assert.ok(lease, `${owner} should have taken the pointer`); + return () => releasePointer(lease); +} +import { LAYER_STATE_REGISTRY } from './layerState.js'; +import { SPRITE_LAYER_ORDER } from './spriteOrder.js'; + +/** The instance under test; each ownership fixture builds a fresh one. */ +let directionsLayer = createApplicationDirectionsLayer(); + +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); +}); + +test('maneuver dots skip departure and arrival and ride the shared ground floor', () => { + assert.deepEqual(stepMarkerIndices(route.steps), [1]); + assert.deepEqual(stepMarkerIndices([]), []); + assert.deepEqual(stepMarkerIndices(null), []); + assert.deepEqual( + stepMarkerIndices([{}, {}, {}, {}, {}]), + [1, 2, 3], + 'every decision between the endpoints gets a dot', + ); + // Denver: ground is ~1.6 km ellipsoidal. A dot fixed near zero would sit a + // kilometre and a half under the junction it marks. + const lift = GROUND_FLOOR_LIFT_M; + assert.equal(stepMarkerHeightM(1609.3, lift), 1609.3 + lift); + assert.equal(stepMarkerHeightM(0, lift), lift); + assert.equal( + stepMarkerHeightM(null, lift), + null, + 'a cold cell has no honest height', + ); + assert.equal(stepMarkerHeightM(undefined, lift), null); + assert.equal(stepMarkerHeightM(Number.NaN, lift), null); +}); + +test('the turn-by-turn list is ordered, labelled, and highlights the flown step', () => { + assert.equal(directionsStepList({ route: null }), null); + assert.equal(directionsStepList({ route: { steps: [] } }), null); + const list = directionsStepList({ + route, + selectedStep: null, + flightStep: null, + }); + assert.deepEqual( + list.items.map((item) => item.ordinal), + [1, 2, 3], + 'items are in route order', + ); + assert.deepEqual( + list.items.map((item) => item.text), + route.steps.map((step) => step.instruction), + ); + assert.deepEqual( + list.items.map((item) => item.lead), + ['8 m', '240 m', '0 m'], + ); + assert.deepEqual( + list.items.map((item) => item.params), + [{ step: 0 }, { step: 1 }, { step: 2 }], + ); + assert.equal( + list.items.some((item) => item.active), + false, + ); + + const selected = directionsStepList({ + route, + selectedStep: 2, + flightStep: null, + }); + assert.deepEqual( + selected.items.map((item) => item.active), + [false, false, true], + ); + assert.deepEqual( + selected.items.map((item) => item.current), + [false, false, false], + ); + + // A running FLY owns the highlight; the clicked step yields to it. + const flying = directionsStepList({ route, selectedStep: 2, flightStep: 1 }); + assert.deepEqual( + flying.items.map((item) => item.active), + [false, true, false], + ); + assert.deepEqual( + flying.items.map((item) => item.current), + [false, true, false], + ); + + const rowList = directionsRowControls({ + ...idle, + a: A, + b: B, + status: 'ready', + route, + }).list; + assert.equal(rowList.items.length, 3); + assert.equal(rowList.ariaLabel, 'Turn-by-turn directions'); + assert.equal(directionsRowControls(idle).list, null, 'no route, no list'); +}); + +test('the flown step follows distance travelled, never running off either end', () => { + const steps = [{ distanceM: 100 }, { distanceM: 250 }, { distanceM: 0 }]; + assert.equal(stepIndexAtDistance(steps, 0), 0); + assert.equal(stepIndexAtDistance(steps, 99), 0); + assert.equal(stepIndexAtDistance(steps, 100), 1); + assert.equal(stepIndexAtDistance(steps, 349), 1); + assert.equal(stepIndexAtDistance(steps, 350), 2, 'the arrival step is last'); + assert.equal(stepIndexAtDistance(steps, 99999), 2); + assert.equal(stepIndexAtDistance(steps, -5), 0); + assert.equal(stepIndexAtDistance(steps, Number.NaN), 0); + assert.equal(stepIndexAtDistance([], 10), null); + assert.equal(stepIndexAtDistance(null, 10), null); +}); + +test('the FLY chip reports a flight in progress', () => { + const flying = directionsRowControls({ + ...idle, + a: A, + b: B, + status: 'ready', + route, + flightStep: 1, + }).chips.find((chip) => chip.id === 'fly'); + assert.equal(flying.label, 'FLYING'); + assert.equal(flying.busy, true); + assert.equal(flying.active, true); +}); + +test('a blocked pointer is reported instead of silently doing nothing', () => { + const blocked = directionsStats({ ...idle, pointerBlocked: true }); + assert.match(blocked.error, /Another map tool is using clicks/); + assert.equal(blocked.count, 0); +}); + +test('a step selection can be requested and cleared through params', () => { + assert.deepEqual(normalizeDirectionsParams({ step: 3 }), { step: 3 }); + assert.deepEqual(normalizeDirectionsParams({ step: 0 }), { step: 0 }); + assert.deepEqual(normalizeDirectionsParams({ step: null }), { step: null }); + assert.deepEqual(normalizeDirectionsParams({ step: -1 }), { step: null }); + assert.deepEqual(normalizeDirectionsParams({ step: 'two' }), { step: null }); +}); + +/** + * Drive the live layer module with the overlay host stubbed out. No viewer is + * needed for the arming paths: they are pure state plus the pointer claim. + */ +function ownershipFixture(t) { + directionsLayer = createApplicationDirectionsLayer(); + directionsLayer._setOverlayHostForTest({ + setEntries() {}, + setVisible() {}, + clearSource() {}, + }); + resetPointerOwnership(); + t.after(() => { + directionsLayer.setParams({ clear: true }); + directionsLayer._setOverlayHostForTest(null); + resetPointerOwnership(); + }); +} + +test('placing an endpoint takes the pointer, and every exit path gives it back', (t) => { + ownershipFixture(t); + + assert.equal(isPointerFree(), true); + assert.equal(directionsLayer.setParams({ arm: 'a' }), true); + assert.equal( + pointerOwner(), + DIRECTIONS_POINTER_OWNER, + 'arming claims the pointer', + ); + assert.equal( + directionsLayer.getRowControls().chips.find((c) => c.id === 'set-a').active, + true, + ); + + // Re-arming for the other endpoint keeps the same claim; it never stacks. + directionsLayer.setParams({ arm: 'b' }); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + + // Cancel (clicking the armed chip again). + directionsLayer.setParams({ arm: null }); + assert.equal(isPointerFree(), true, 'cancelling releases'); + assert.equal( + directionsLayer.getRowControls().chips.find((c) => c.id === 'set-b').active, + false, + ); + + // CLEAR while armed. + directionsLayer.setParams({ arm: 'a' }); + directionsLayer.setParams({ clear: true }); + assert.equal(isPointerFree(), true, 'CLEAR releases'); + + // disable() while armed. + directionsLayer.setParams({ arm: 'a' }); + directionsLayer.disable(null); + assert.equal(isPointerFree(), true, 'disabling the layer releases'); + + // destroy() while armed, on a layer that was never enabled. + directionsLayer.setParams({ arm: 'b' }); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + directionsLayer.destroy({ scene: { primitives: { remove() {} } } }); + assert.equal(isPointerFree(), true, 'destroy releases'); +}); + +test('arming is refused, and said so, while another tool holds the pointer', (t) => { + ownershipFixture(t); + + const releaseDraw = claimAs('draw'); + assert.equal( + directionsLayer.setParams({ arm: 'a' }), + true, + 'the write is accepted', + ); + assert.equal( + pointerOwner(), + 'draw', + "the other tool's claim is never stolen", + ); + const chips = directionsLayer.getRowControls().chips; + assert.equal( + chips.find((chip) => chip.id === 'set-a').active, + false, + 'nothing is armed', + ); + assert.match( + directionsLayer.getStats().error, + /Another map tool is using clicks/, + 'the row says why the click did nothing', + ); + + // Once the other tool lets go, arming works and the complaint clears. + assert.equal(releaseDraw(), true); + directionsLayer.setParams({ arm: 'a' }); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + assert.equal(directionsLayer.getStats().error, null); +}); + +test('a release names this layer, so it cannot free a successor tool claim', (t) => { + ownershipFixture(t); + + directionsLayer.setParams({ arm: 'a' }); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + // A superseding teardown: the layer is disabled, then another tool claims. + directionsLayer.disable(null); + const releaseDraw = claimAs('draw'); + // A late second teardown of ours must not free the draw tool's claim. + directionsLayer.disable(null); + directionsLayer.setParams({ clear: true }); + assert.equal(pointerOwner(), 'draw'); + releaseDraw(); +}); + +test('the pointer claim outlives the click that consumed it', async (t) => { + ownershipFixture(t); + // Every layer binds its own handler to the same canvas and they all run + // inside ONE browser event. Releasing the claim the moment the endpoint is + // placed hands the rest of that same click to every ambient handler + // registered after this one — which is how placing A on top of a bikeshare + // station also deselected the station. + directionsLayer.setParams({ arm: 'a' }); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + + assert.equal( + directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }), + true, + ); + // Still inside the dispatch: an ambient handler that runs after this one + // asks the same question the real ones ask, and must be told to stand down. + assert.equal( + isPointerFree(), + false, + 'a later handler in the same click yields', + ); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + // A microtask checkpoint runs BETWEEN two DOM listeners, so the release must + // not be on one. + await Promise.resolve(); + await Promise.resolve(); + assert.equal( + isPointerFree(), + false, + 'a microtask is not the end of the click', + ); + + // The click is over; the pointer goes back. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + isPointerFree(), + true, + 'the claim is returned once the click ends', + ); + + // A bad placement request changes nothing. + directionsLayer.setParams({ arm: 'b' }); + assert.equal(directionsLayer.placeEndpoint('c', { lat: 1, lon: 1 }), false); + assert.equal( + directionsLayer.placeEndpoint('b', { lat: Number.NaN, lon: 1 }), + false, + ); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER, 'still armed'); +}); + +test('a teardown during the pending release still frees the pointer at once', async (t) => { + ownershipFixture(t); + directionsLayer.setParams({ arm: 'a' }); + directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }); + assert.equal(isPointerFree(), false); + // Disabling mid-click must not wait for a timer that may never be reached. + directionsLayer.disable(null); + assert.equal(isPointerFree(), true); + // And the pending timer cannot then release someone else's later claim. + const releaseDraw = claimAs('draw'); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(pointerOwner(), 'draw'); + releaseDraw(); +}); + +test('a cold ground cell is re-read for as long as the terrain proxy can take', () => { + // The proxy behind the shared floor can take tens of seconds on a cold + // cache. Twelve quick retries covered four seconds of that. + assert.ok( + STEP_ANCHOR_DEADLINE_MS >= 60_000, + `the budget must outlast a slow terrain round trip, is ${STEP_ANCHOR_DEADLINE_MS} ms`, + ); + assert.equal(stepAnchorDelayMs(0, 0), STEP_ANCHOR_RETRY_MS); + assert.equal( + stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS - 1, 1000), + STEP_ANCHOR_RETRY_MS, + ); + assert.equal( + stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS, 2000), + STEP_ANCHOR_SLOW_RETRY_MS, + 'the re-reads back off once the quick ones have not settled it', + ); + // The last wait never overshoots the budget, and past it there is no wait. + assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS - 100), 100); + assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS), null); + assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS + 1), null); + assert.equal(stepAnchorDelayMs(0, Number.NaN), null); +}); + +test('a route cut off at the step cap says so instead of ending at a random turn', () => { + const cut = { ...route, stepsTruncated: true }; + const list = directionsStepList({ route: cut }); + assert.equal(list.items.length, route.steps.length + 1); + const last = list.items.at(-1); + assert.match(last.text, /Only the first 3 turns of this route are shown/); + assert.equal(last.disabled, true, 'the note is not an action'); + assert.equal(last.params, undefined, 'and carries no command'); + assert.match( + directionsStats({ ...idle, a: A, b: B, status: 'ready', route: cut }) + .coverage, + /first 3 turns$/, + ); + // A complete route says nothing extra. + assert.equal(directionsStepList({ route }).items.length, route.steps.length); + assert.equal( + normalizeRoutePayload( + { ok: true, geometry: route.geometry, steps: route.steps }, + 'car', + ).stepsTruncated, + false, + ); + assert.equal( + normalizeRoutePayload( + { + ok: true, + geometry: route.geometry, + steps: route.steps, + stepsTruncated: true, + }, + 'car', + ).stepsTruncated, + true, + ); +}); + +test('re-arming reuses the lease this layer already holds', (t) => { + ownershipFixture(t); + // A claim never stacks, not even under the same name — two live instances of + // one tool are two owners. So arming B after arming A must not ask for a + // second claim it cannot get. + assert.equal(directionsLayer.setParams({ arm: 'a' }), true); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); + assert.equal(directionsLayer.setParams({ arm: 'b' }), true); + assert.equal( + pointerOwner(), + DIRECTIONS_POINTER_OWNER, + 'the layer still holds the pointer after re-arming', + ); + const chips = directionsLayer.getRowControls().chips; + assert.equal(chips.find((chip) => chip.id === 'set-b').active, true); + assert.equal( + directionsLayer.getStats().error, + null, + 'and nothing is refused', + ); +}); + +test('a stale lease cannot free the claim a later arming holds', async (t) => { + ownershipFixture(t); + directionsLayer.setParams({ arm: 'a' }); + directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }); + // The release for that click is pending. Arming again before it fires must + // keep the pointer, not lose it to the timer a moment later. + directionsLayer.setParams({ arm: 'b' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal( + pointerOwner(), + DIRECTIONS_POINTER_OWNER, + 'the pending release must not disarm the new arming', + ); +}); + +test('a refused arming names the tool holding the pointer and how to leave it', () => { + // Owner field test: he pressed SET A while Draw was open, nothing happened, + // and he concluded Directions was broken. Silence was the bug. + assert.equal( + pointerBlockedMessage('draw', 'a'), + 'Draw is active — press Escape twice to leave Draw, then set A', + ); + assert.equal( + pointerBlockedMessage('draw', 'b'), + 'Draw is active — press Escape twice to leave Draw, then set B', + ); + // A tool nobody has written an exit for still gets a usable sentence. + assert.equal( + pointerBlockedMessage('measure', 'a'), + 'measure is active — turn measure off, then set A', + ); + // Nothing to say when nothing is in the way, or when we are the holder. + assert.equal(pointerBlockedMessage(null, 'a'), null); + assert.equal(pointerBlockedMessage('', 'a'), null); + assert.equal(pointerBlockedMessage(' ', 'a'), null); + assert.equal(pointerBlockedMessage(DIRECTIONS_POINTER_OWNER, 'a'), null); + // Every exit the table knows names its tool and says how to leave it. + for (const [id, exit] of Object.entries(POINTER_TOOL_EXITS)) { + assert.match( + pointerBlockedMessage(id, 'a'), + new RegExp(`^${exit.name} is active — `), + ); + assert.match(pointerBlockedMessage(id, 'a'), /, then set A$/); + } +}); + +test('the refusal reaches the app toast, and a successful arming says nothing', (t) => { + ownershipFixture(t); + const toasts = []; + directionsLayer.attachShellServices({ + showToast: (text) => toasts.push(text), + }); + t.after(() => directionsLayer.attachShellServices(null)); + + const releaseDraw = claimAs('draw'); + directionsLayer.setParams({ arm: 'a' }); + assert.deepEqual(toasts, [ + 'Draw is active — press Escape twice to leave Draw, then set A', + ]); + // The row status the operator can also read is still set. + assert.match( + directionsLayer.getStats().error, + /Another map tool is using clicks/, + ); + + // Once Draw lets go, arming works and says nothing at all. + releaseDraw(); + directionsLayer.setParams({ arm: 'a' }); + assert.equal(toasts.length, 1, 'a successful arming is silent'); + assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index ed73b98cc..4d380db27 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -279,6 +279,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'alpr-cameras', token: 'p', 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 4fada4dcb..f985eb71a 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -158,8 +158,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, 17); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17); + assert.equal(REGISTERED_LAYER_IDS.length, 18); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18); 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 a1ae9d226..b719b5934 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -14,6 +14,17 @@ export class DataLayerManager extends LayerLifecycle { _refreshTogglePanel() { this._presentation.refresh(); } + + /** + * Repaint the toggle panel now. For layers whose data arrives outside their + * manager tick (camera-driven loads such as Transit's proximity polls), so a + * row shows its count when the data lands instead of at the next interval. + * One DOM pass; skipped while the document is hidden. + */ + refreshLayerStats() { + this._refreshTogglePanel(); + } + _buildMetaText(layer) { return this._presentation.panel._buildMetaText(layer); } diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js new file mode 100644 index 000000000..5ee11602a --- /dev/null +++ b/src/data/routeSteps.js @@ -0,0 +1,222 @@ +/** + * @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. + * A route with more maneuvers than `ROUTE_STEPS_MAX` is cut off there and + * reported as cut off, because the last step of a route is its arrival: a + * silently truncated list reads as a complete set of directions that simply + * stops in the middle of a motorway. + * @param {{legs?: Array<{steps?: object[]}>}} route OSRM route object. + * @returns {{steps: 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}>, truncated: boolean}} + */ +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 { steps: out, truncated: true }; + } + } + return { steps: out, truncated: false }; +} + +/** + * "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 000000000..a3c8cf227 --- /dev/null +++ b/src/data/routeSteps.test.mjs @@ -0,0 +1,226 @@ +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, truncated } = normalizeOsrmSteps(route); + assert.equal(truncated, false, 'a short route is complete'); + 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), { steps: [], truncated: false }); + assert.deepEqual(normalizeOsrmSteps({ legs: [{ steps: null }] }), { + steps: [], + truncated: false, + }); +}); + +test('the step list is capped, and the cap is reported rather than hidden', () => { + const many = 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, + })); + const cut = normalizeOsrmSteps({ legs: [{ steps: many }] }); + assert.equal(cut.steps.length, ROUTE_STEPS_MAX); + // The arrival step is among the ones dropped; a caller that is not told so + // would present a route that simply stops in the middle of a road. + assert.equal(cut.truncated, true); + const exact = normalizeOsrmSteps({ + legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }], + }); + assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1); + assert.equal(exact.truncated, false, 'a route that fits is not cut'); +}); + +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 3b0b4b5a5..aaabe14a5 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/layers/directions/index.js b/src/layers/directions/index.js new file mode 100644 index 000000000..bb93bbeb2 --- /dev/null +++ b/src/layers/directions/index.js @@ -0,0 +1,1432 @@ +/** + * @module layers/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 { + formatRouteDistance, + formatRouteDuration, +} from '../../data/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; + +/** Pointer-ownership id while a globe click is placing an endpoint. */ +export const DIRECTIONS_POINTER_OWNER = 'directions'; + +/** + * How long to keep re-reading the shared ground floor for a maneuver dot whose + * cell has not answered yet. The terrain proxy behind that floor can take tens + * of seconds on a cold cache, so this is a wall-clock budget comfortably past + * it rather than a handful of quick retries — a dot that resolves late still + * appears. Reroute, CLEAR, disable and destroy all cancel it. + */ +export const STEP_ANCHOR_DEADLINE_MS = 90_000; +/** Gap between the first re-reads (ms); it widens after the first few. */ +export const STEP_ANCHOR_RETRY_MS = 400; +/** Gap once the quick re-reads have not settled it (ms). */ +export const STEP_ANCHOR_SLOW_RETRY_MS = 2000; +/** Re-reads at the quick gap before backing off. */ +export const STEP_ANCHOR_FAST_ATTEMPTS = 5; +/** How often the row repaints the highlighted step while FLY is running (ms). */ +export const FLIGHT_PROGRESS_MS = 250; + +// --------------------------------------------------------------------------- +// 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; + if (params.step !== undefined) { + out.step = + Number.isInteger(params.step) && params.step >= 0 ? params.step : null; + } + 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, selectedStep, flightStep } = 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 }, + }); + const flying = Number.isInteger(flightStep); + chips.push({ + id: 'fly', + label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY', + disabled: !route || routing, + busy: routing || flying, + active: flying, + state: routing || flying ? '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: [], + list: directionsStepList({ + route, + selectedStep: selectedStep ?? null, + flightStep: flightStep ?? null, + }), + }; +} + +/** + * Plain names for the tools that can be holding the pointer, and how to leave + * each one. A tool missing from here still gets a usable sentence from its id. + */ +export const POINTER_TOOL_EXITS = Object.freeze({ + draw: Object.freeze({ + name: 'Draw', + leave: 'press Escape twice to leave Draw', + }), +}); + +/** + * What to say when a globe click cannot be armed because something else owns + * the pointer. + * + * The claim failing used to be silent: the chip simply did not light and the + * operator was left to guess which of the open tools was eating the click + * (owner field test — he thought Directions was broken). So the message names + * the tool and says how to put it down. Pure. + * @param {string|null} owner Current pointer-owner id. + * @param {'a'|'b'} which Endpoint that was being armed. + * @returns {string|null} Toast text, or null when nothing holds the pointer. + */ +export function pointerBlockedMessage(owner, which) { + const id = typeof owner === 'string' ? owner.trim() : ''; + if (!id || id === DIRECTIONS_POINTER_OWNER) return null; + const endpoint = which === 'b' ? 'B' : 'A'; + const known = POINTER_TOOL_EXITS[id]; + const name = known?.name || id; + const leave = known?.leave || `turn ${name} off`; + return `${name} is active — ${leave}, then set ${endpoint}`; +} + +/** + * Which maneuvers get a dot on the globe: every one except departure and + * arrival, which the A and B markers already stand for. + * @param {object[]} steps + * @returns {number[]} Step indices. + */ +export function stepMarkerIndices(steps) { + const list = Array.isArray(steps) ? steps : []; + const out = []; + for (let index = 1; index < list.length - 1; index += 1) out.push(index); + return out; +} + +/** + * Render height for a maneuver dot. + * + * A dot fixed at a couple of metres ellipsoidal is at sea level, which in + * Denver (ground ~1.6 km) puts it a kilometre and a half under the city: on an + * oblique view it sits visibly off its own junction. So the dot reads the same + * shared ground floor every other anchored point in the app reads + * (`cachedGroundFloor` — the rendered mesh cell on the photoreal stack, the + * Re:Earth DEM cell on the keyless terrain stacks) and lifts clear of it. + * Until that cell is warm there is no honest height, and the caller keeps the + * dot hidden rather than drawing it somewhere wrong. + * @param {number|null} floorM Ellipsoidal ground for the cell, or null. + * @returns {number|null} Ellipsoidal render height, or null while unknown. + */ +export function stepMarkerHeightM(floorM, liftM) { + return Number.isFinite(floorM) ? floorM + liftM : null; +} + +/** + * How long to wait before re-reading the ground floor again, or null when the + * budget is spent. Quick at first, because most cells answer immediately from + * a warm cache; slower afterwards, because what is left is a network round + * trip. Pure. + * @param {number} attempt Re-reads already made (0 for the first wait). + * @param {number} elapsedMs Milliseconds since anchoring started. + * @returns {number|null} Delay in ms, or null to stop. + */ +export function stepAnchorDelayMs(attempt, elapsedMs) { + if (!Number.isFinite(elapsedMs) || elapsedMs >= STEP_ANCHOR_DEADLINE_MS) + return null; + const delay = + attempt < STEP_ANCHOR_FAST_ATTEMPTS + ? STEP_ANCHOR_RETRY_MS + : STEP_ANCHOR_SLOW_RETRY_MS; + return Math.min(delay, STEP_ANCHOR_DEADLINE_MS - elapsedMs); +} + +/** + * The ordered turn-by-turn list for the layer row. Pure. + * @param {{route: object|null, selectedStep: number|null, flightStep: number|null}} state + * @returns {{ariaLabel: string, items: object[]}|null} + */ +export function directionsStepList({ + route, + selectedStep = null, + flightStep = null, +}) { + const steps = route?.steps; + if (!Array.isArray(steps) || !steps.length) return null; + const active = Number.isInteger(flightStep) ? flightStep : selectedStep; + const items = steps.map((step, index) => ({ + id: `step-${index}`, + ordinal: index + 1, + lead: formatRouteDistance(step.distanceM) || '—', + text: step.instruction, + active: index === active, + current: Number.isInteger(flightStep) && index === flightStep, + params: { step: index }, + })); + if (route.stepsTruncated) { + // The arrival step is missing, so the list must not end as if the reader + // had arrived. A plain, unclickable last line says where it really stops. + items.push({ + id: 'step-truncated', + ordinal: items.length + 1, + lead: '', + text: `Only the first ${steps.length} turns of this route are shown`, + active: false, + current: false, + disabled: true, + }); + } + return { ariaLabel: 'Turn-by-turn directions', items }; +} + +/** + * Which maneuver the camera is on, given how far along the route it has flown. + * Steps carry the length of the leg that FOLLOWS them, so the step in force is + * the last one whose cumulative start is at or behind the camera. Pure. + * @param {object[]} steps + * @param {number} traveledM Metres flown from the start of the route. + * @returns {number|null} + */ +export function stepIndexAtDistance(steps, traveledM) { + if (!Array.isArray(steps) || !steps.length) return null; + if (!Number.isFinite(traveledM) || traveledM < 0) return 0; + let start = 0; + for (let index = 0; index < steps.length; index += 1) { + const end = start + Math.max(0, Number(steps[index]?.distanceM) || 0); + if (traveledM < end) return index; + start = end; + } + return steps.length - 1; +} + +/** + * 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, + pointerBlocked, + } = state; + const source = 'OSM routing'; + if (pointerBlocked) { + return { + count: route?.steps.length || 0, + lastUpdate, + error: 'Another map tool is using clicks — close it, then SET A again', + status: 'empty', + source, + }; + } + 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}` + + (route.stepsTruncated ? ` · first ${route.steps.length} turns` : ''); + 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, + // A route with more maneuvers than the proxy will serve is cut off, and + // the cut is shown rather than passed off as the whole route. + stepsTruncated: payload.stepsTruncated === true, + mode, + }; +} + +/** + * Construct one Directions layer over the application scene owners. + * + * Each catalog builds its own, so two applications on a page never share the + * route, the markers, the pointer claim or the flight this layer owns. The + * shared services are injected, which is what keeps this module's own imports + * down to Cesium and the pure step formatter. + * @param {{services: object}} options + * @returns {object} The layer module the data manager registers. + */ +export function createDirectionsLayer({ services }) { + /** + * Where the maneuver card is drawn. Defaults to the shared world-overlay host; + * tests swap it. Resolved on use, not at import, because the services arrive + * after this module is evaluated. + */ + const DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: (...args) => services.overlays.setOverlayEntries(...args), + setVisible: (...args) => services.overlays.setOverlaySourceVisible(...args), + clearSource: (...args) => services.overlays.clearOverlaySource(...args), + }); + 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; + /** Set when arming failed because another tool holds the pointer. */ + let _pointerBlocked = false; + /** + * What `claimPointer` handed back for the claim this layer currently holds, or + * null. Held rather than re-derived so the release names THIS claim: a claim + * that has already been superseded cannot free its successor's. + */ + let _pointerClaim = null; + /** Pending release of that claim, once the click being handled is over. */ + let _releaseTimer = null; + /** Id of the route flight THIS layer started, while it is still running. */ + let _flightId = 0; + let _flightTimer = null; + /** Step the camera is on during FLY, or null. */ + let _flightStep = null; + let _anchorTimer = null; + let _anchorAttempts = 0; + let _anchorStartedAt = 0; + /** + * Camera seams handed over by the UI shell: the one navigation-authority + * facade every camera owner goes through, plus the shared ground-floor + * read/warm the route dolly uses so it does not fly a mountain at sea level. + */ + let _shellSeams = null; + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + function state() { + return { + enabled: _enabled, + mode: _mode, + armed: _armed, + a: _a, + b: _b, + status: _status, + error: _error, + route: _route, + lastUpdate: _lastUpdate, + selectedStep: _selectedStep, + flightStep: _flightStep, + pointerBlocked: _pointerBlocked, + }; + } + + function notifyRow() { + try { + _rowControlsListener?.(); + } catch { + /* listener is best-effort */ + } + _dataManager?.refreshLayerStats?.(); + } + + function syncRenderHold() { + const shouldHold = _enabled && Boolean(_routeEntity); + if (shouldHold && !_renderHeld) { + services.render.holdContinuousRender('directions'); + _renderHeld = true; + } else if (!shouldHold && _renderHeld) { + services.render.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 ( + !services.scenePick.isPickedWorldPosition(cartesian) && + typeof _viewer?.camera?.pickEllipsoid === 'function' + ) { + try { + cartesian = _viewer.camera.pickEllipsoid( + screenPosition, + Cesium.Ellipsoid.WGS84, + ); + } catch { + cartesian = null; + } + } + if (!services.scenePick.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(); + stopStepAnchoring(); + removeEntity(_routeEntity); + _routeEntity = null; + _stepPoints?.removeAll(); + syncRenderHold(); + } + + function stopStepAnchoring() { + if (_anchorTimer) clearTimeout(_anchorTimer); + _anchorTimer = null; + _anchorAttempts = 0; + _anchorStartedAt = 0; + } + + /** + * Put every maneuver dot on the ground at its junction, and re-read the shared + * floor while its cells are still warming. A dot whose cell has not answered + * yet stays hidden: an unplaced dot is better than one drawn a kilometre below + * the city it belongs to. + */ + function anchorStepPoints() { + _anchorTimer = null; + if (!_stepPoints || !_route || !_enabled) return; + const steps = _route.steps; + const pending = []; + let unresolved = 0; + for (const index of stepMarkerIndices(steps)) { + const step = steps[index]; + const point = findStepPoint(index); + if (!point) continue; + const height = stepMarkerHeightM( + services.ground.cachedGroundFloor(step.lat, step.lon), + services.ground.GROUND_FLOOR_LIFT_M, + ); + if (height === null) { + unresolved += 1; + point.show = false; + pending.push({ lat: step.lat, lon: step.lon }); + continue; + } + point.position = Cesium.Cartesian3.fromDegrees( + step.lon, + step.lat, + height, + ); + point.show = true; + } + // A card already open on a step that has just been anchored moves with it. + if (_selectedStep !== null) refreshStepCard(_selectedStep); + services.render.governorRequestRender('directions-anchor'); + if (!unresolved) { + _anchorAttempts = 0; + _anchorStartedAt = 0; + return; + } + services.ground.warmGroundFloor(pending); + const delay = stepAnchorDelayMs( + _anchorAttempts, + Date.now() - _anchorStartedAt, + ); + _anchorAttempts += 1; + if (delay === null) return; + _anchorTimer = setTimeout(anchorStepPoints, delay); + } + + function drawRoute(route) { + clearRouteGraphics(); + if (!_viewer) return; + services.annotations.ensureFlowFabricRegistered(); + const positions = Cesium.Cartesian3.fromDegreesArray(route.geometry.flat()); + _routeEntity = _viewer.entities.add({ + id: 'directions:route', + polyline: { + positions, + width: 9, + material: new services.annotations.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. The dots + // start hidden and appear as anchorStepPoints resolves each ground cell. + const cells = []; + for (const index of stepMarkerIndices(route.steps)) { + const step = route.steps[index]; + cells.push({ lat: step.lat, lon: step.lon }); + _stepPoints.add({ + id: `directions:step:${index}`, + position: Cesium.Cartesian3.fromDegrees(step.lon, step.lat, 0), + color: STEP_COLOR, + pixelSize: STEP_PIXEL_SIZE, + outlineColor: STEP_OUTLINE, + outlineWidth: 2, + show: false, + // The shared ground floor is a coarse cell, and on the photoreal stack + // the rendered mesh can stand above it. Keeping the dot always visible + // is the same choice every other anchored sprite in the app makes. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + } + services.ground.warmGroundFloor(cells); + _anchorAttempts = 0; + _anchorStartedAt = Date.now(); + anchorStepPoints(); + syncRenderHold(); + services.sprites.restoreSpriteOrder(_viewer); + services.render.governorRequestRender('directions-route'); + } + + async function requestRoute() { + if (!_a || !_b || !_enabled) return; + // The route under an owned flight is about to be replaced, and a dolly + // cannot be re-pointed mid-cut. Land it first (only if it is still ours). + cancelOwnedFlight('directions-reroute'); + _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' }, + }); + const payload = await response.json(); + if (response.status === 429) { + throw new Error( + typeof payload?.error === 'string' && payload.error + ? `${payload.error} — try again in a moment` + : 'Routing is rate limited — try again in a moment', + ); + } + 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'; + // A fetch that never reaches the proxy rejects with the browser's own + // wording ("Failed to fetch"), which tells a reader nothing. Say what + // happened instead. + _error = + error?.name === 'AbortError' + ? 'Routing timed out' + : error?.name === 'TypeError' + ? 'Routing unavailable — no response from the routing service' + : error?.message || 'Routing unavailable'; + } finally { + clearTimeout(timer); + if (_routeAbort === controller) _routeAbort = null; + if (seq === _routeSeq) notifyRow(); + services.render.governorRequestRender('directions-route'); + } + } + + /** Stop arming, and give the pointer back if this layer holds it. */ + function releaseClaim() { + if (_releaseTimer) clearTimeout(_releaseTimer); + _releaseTimer = null; + if (_pointerClaim !== null) services.input.releasePointer(_pointerClaim); + _pointerClaim = null; + } + + /** + * Give the pointer back, but only once the click that consumed it has finished + * being dispatched. + * + * Every layer binds its own handler to the same canvas, and they all run + * inside ONE browser event. Releasing the claim the instant this layer places + * an endpoint hands the rest of that same click to every ambient handler + * registered after this one — placing A on top of a bikeshare station also + * deselected the station. A timer, not a microtask: a microtask checkpoint + * runs between two DOM listeners, which is exactly the gap this closes. + */ + function releaseClaimAfterDispatch() { + if (_pointerClaim === null || _releaseTimer) return; + _releaseTimer = setTimeout(() => { + _releaseTimer = null; + releaseClaim(); + }, 0); + } + + /** + * Stop arming. + * @param {{afterDispatch?: boolean}} [options] `afterDispatch` holds the + * pointer claim until the click being handled has finished dispatching. + */ + function disarm({ afterDispatch = false } = {}) { + _armed = null; + _pointerBlocked = false; + if (afterDispatch) releaseClaimAfterDispatch(); + else releaseClaim(); + } + + /** + * Place one endpoint at a picked ground point and reroute when both are set. + * + * The globe click handler is the only caller in the running app; it is + * exported because a ScreenSpaceEventHandler needs a real canvas, and the + * ownership timing this function decides is exactly what has to be tested. + * @param {'a'|'b'} which Which endpoint to place. + * @param {{lat: number, lon: number}} point Ground point in degrees. + * @returns {boolean} Whether the endpoint was placed. + */ + function placeDirectionsEndpoint(which, point) { + if (which !== 'a' && which !== 'b') return false; + if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) + return false; + // The claim is held until this click has finished being dispatched, so the + // ambient handlers that run after this one still see the pointer as taken. + disarm({ afterDispatch: true }); + if (which === 'a') _a = point; + else _b = point; + placeMarker(which, point); + if (_a && _b) void requestRoute(); + else notifyRow(); + services.render.governorRequestRender('directions-place'); + return true; + } + + /** + * Arm (or cancel) a globe click for one endpoint. + * + * Placement is a TOOL: it takes the pointer so no ambient layer also selects + * whatever happened to be under the click. If another tool already holds it, + * this refuses — nothing is armed and the row says why. + * @param {null|'a'|'b'} which + * @returns {boolean} Whether the layer is armed after this call. + */ + function setArmed(which) { + if (!which) { + disarm(); + return false; + } + // A claim does not stack, not even under the same name — two live instances + // of one tool are two owners. So re-arming for the other endpoint reuses the + // lease already held instead of asking for a second one. + if (!services.input.isLeaseCurrent(_pointerClaim)) { + const lease = services.input.claimPointer(DIRECTIONS_POINTER_OWNER); + if (!lease) { + _armed = null; + _pointerBlocked = true; + // The row already says it, but the row is not where the operator is + // looking after pressing a chip — say it where the app says everything. + const message = pointerBlockedMessage( + services.input.pointerOwner(), + which, + ); + if (message) _shellSeams?.showToast?.(message); + return false; + } + _pointerClaim = lease; + } + // A pending release from the previous placement would otherwise hand the + // pointer back while this new arming still needs it. + if (_releaseTimer) { + clearTimeout(_releaseTimer); + _releaseTimer = null; + } + _pointerBlocked = false; + _armed = which; + _clearStepSelection(); + return true; + } + + function clearAll() { + _routeAbort?.abort(); + _routeAbort = null; + _routeSeq += 1; + cancelOwnedFlight('directions-clear'); + disarm(); + _a = null; + _b = null; + _route = null; + _status = 'idle'; + _error = null; + removeEntity(_markerA); + removeEntity(_markerB); + _markerA = null; + _markerB = null; + clearRouteGraphics(); + services.render.governorRequestRender('directions-clear'); + } + + /** + * Stop the route flight THIS layer started — and only that one. The camera is + * shared: by the time CLEAR is pressed the user may have grabbed it, voice may + * have flown somewhere, or a tracked aircraft may own it. Cancelling by id + * leaves every one of those alone. + * @param {string} reason + * @returns {boolean} Whether a flight of ours was stopped. + */ + function cancelOwnedFlight(reason) { + stopFlightProgress(); + if (!_flightId) return false; + const { wasActive } = services.camera.interruptCameraMotionIfActive( + _flightId, + reason, + ); + _flightId = 0; + _flightStep = null; + return wasActive; + } + + function stopFlightProgress() { + if (_flightTimer) clearInterval(_flightTimer); + _flightTimer = null; + } + + /** Follow the owned flight so the list can highlight the step being flown. */ + function trackFlightProgress() { + stopFlightProgress(); + if (!_flightId) return; + _flightTimer = setInterval(() => { + const motion = services.camera.getActiveCameraMotion(); + if (!motion || motion.motionId !== _flightId) { + // The flight finished, or something else took the camera. Either way it + // is no longer ours to stop. + _flightId = 0; + _flightStep = null; + stopFlightProgress(); + notifyRow(); + return; + } + const next = stepIndexAtDistance(_route?.steps, motion.traveledM); + if (next !== _flightStep) { + _flightStep = next; + notifyRow(); + } + }, FLIGHT_PROGRESS_MS); + } + + function flyCurrentRoute() { + if (!_route || !_viewer) return false; + cancelOwnedFlight('directions-refly'); + services.camera.initCameraVerbs(_viewer); + const seams = _shellSeams; + const result = services.camera.flyRoute( + [ + { + type: 'route', + label: 'Directions', + path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), + }, + ], + { speed: 'normal' }, + // The dolly reads the shared ground floor (and warms the corridor ahead of + // itself) exactly as the voice route flight does; without it a mountain + // corridor is flown at sea level. + typeof seams?.floorFn === 'function' ? seams.floorFn : null, + // One camera owner: the UI shell's immediate-navigation facade stamps the + // navigation generation and releases whatever held the camera before the + // flight starts. Without the shell (tests, a bare viewer) the flight still + // runs, it just has nothing to take the camera from. + typeof seams?.runNavigation === 'function' ? seams.runNavigation : null, + typeof seams?.warmFn === 'function' ? seams.warmFn : null, + ); + if (result?.ok !== true) { + console.warn('[Data:Directions] fly refused:', result?.error || result); + return false; + } + _flightId = result.motionId || 0; + _flightStep = 0; + trackFlightProgress(); + return 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; + } + + /** + * Where a step's card should hang, or null while that is not yet known. + * + * Departure and arrival have no maneuver dot — the A and B markers stand for + * them — and an intermediate dot exists only once its ground cell has + * answered. So the anchor is resolved from the step's own coordinates against + * the same shared floor, and a step whose cell is still cold has no anchor at + * all rather than one at sea level. + * @param {number} index Step index. + * @returns {Cesium.Cartesian3|null} + */ + function stepAnchorPosition(index) { + const step = _route?.steps?.[index]; + if (!step) return null; + const point = findStepPoint(index); + if (point?.show) return Cesium.Cartesian3.clone(point.position); + const height = stepMarkerHeightM( + services.ground.cachedGroundFloor(step.lat, step.lon), + services.ground.GROUND_FLOOR_LIFT_M, + ); + if (height === null) { + services.ground.warmGroundFloor([{ lat: step.lat, lon: step.lon }]); + return null; + } + return Cesium.Cartesian3.fromDegrees(step.lon, step.lat, height); + } + + function refreshStepCard(index) { + const position = stepAnchorPosition(index); + if (!position) return false; + const entry = createDirectionsStepOverlayEntry( + index, + position, + directionsStepCopy(_route.steps, index), + ); + if (!entry) return false; + _overlayHost.setEntries( + DIRECTIONS_STEP_OVERLAY_SOURCE_ID, + [entry], + DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS, + ); + return true; + } + + /** + * Open the card for one step. Every step in the list can be opened, including + * departure and arrival; what a step needs is a resolved ground anchor, not a + * dot primitive. + * @param {number} index Step index. + * @returns {boolean} Whether the card opened. + */ + function _selectStep(index) { + _clearStepSelection(); + if (!_route || !Number.isInteger(index) || !_route.steps[index]) + return false; + if (!refreshStepCard(index)) { + // The ground cell is still cold; the anchoring loop will not open a card + // the reader did not ask for, so say nothing rather than draw it wrong. + return false; + } + _selectedStep = index; + const point = findStepPoint(index); + if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE; + services.render.governorRequestRender('directions-select'); + return true; + } + + 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) { + disarm(); + 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; + // Placement acts only while this layer actually HOLDS the pointer, and the + // maneuver-dot selection below is an ambient handler like any other: it + // yields the moment a tool (this one included) owns the click. + // The lease, not the owner name: a superseded instance of this layer must + // not act on a click its replacement owns. + if (_armed && services.input.isLeaseCurrent(_pointerClaim)) { + const point = pickGround(click.position); + if (!point) return; + placeDirectionsEndpoint(_armed, point); + return; + } + if (!services.input.isPointerFree()) 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); + if (typeof document !== 'undefined') + document.addEventListener('keydown', _onKeyDown); + } + + function _removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + // Teardown can outlive the document (page unload, and every headless test). + if (typeof document !== 'undefined') + 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); + services.sprites.registerSpriteCollection('directions', _stepPoints); + _stepPoints.show = false; + _enabled = false; + _mode = DEFAULT_DIRECTIONS_MODE; + disarm(); + _a = null; + _b = null; + _route = null; + _status = 'idle'; + _error = null; + _flightStep = null; + _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); + services.sprites.restoreSpriteOrder(viewer); + console.log('[Data:Directions] Initialized'); + }, + + /** + * Show the row chips and listen for placement clicks. + * @param {Cesium.Viewer} viewer + */ + enable(viewer) { + _enabled = true; + _pointerBlocked = false; + _stepPoints.show = true; + _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); + _installClickHandler(viewer); + services.picking.registerPickOwner( + 'directions', + (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith('directions:'), + ); + syncRenderHold(); + services.sprites.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(); + services.picking.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) setArmed(patch.arm); + if (patch.swap && _a && _b) { + [_a, _b] = [_b, _a]; + placeMarker('a', _a); + placeMarker('b', _b); + if (_enabled) void requestRoute(); + } + if (patch.step !== undefined) { + if (patch.step === null) _clearStepSelection(); + else _selectStep(patch.step); + } + if (patch.fly) flyCurrentRoute(); + notifyRow(); + services.render.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; + }, + + /** + * Receive the UI shell's camera seams. FLY goes through `runNavigation` — + * the same immediate-navigation facade voice destinations use — so the route + * dolly is one more caller of the single camera owner rather than a second + * one. `floorFn`/`warmFn` are the shared ground floor the dolly flies over. + * `showToast` is the app's own toast, so a refusal is said where the rest of + * the UI says things. + * @param {{runNavigation?: Function, floorFn?: Function, warmFn?: Function, + * showToast?: Function}|null} services + */ + attachShellServices(services) { + _shellSeams = services || null; + }, + + /** + * Tear down entirely. + * @param {Cesium.Viewer} viewer + */ + destroy(viewer) { + if (_enabled) this.disable(viewer); + // disable() has already run these, but destroy() must also be safe on a + // layer that was never enabled: a claim or a flight left behind would + // outlive the whole application. + cancelOwnedFlight('directions-destroy'); + stopStepAnchoring(); + disarm(); + _shellSeams = null; + _dataManager = null; + _rowControlsListener = null; + if (_stepPoints) { + services.sprites.unregisterSpriteCollection('directions', _stepPoints); + viewer.scene.primitives.remove(_stepPoints); + _stepPoints = null; + } + _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); + _viewer = null; + }, + + /** + * Place one endpoint at a picked ground point. The globe click handler is + * the only caller in the running app; it is on the instance because a + * ScreenSpaceEventHandler needs a real canvas, and the ownership timing + * this decides is exactly what has to be tested. + */ + placeEndpoint: placeDirectionsEndpoint, + + /** Test seam: swap the shared overlay host. */ + _setOverlayHostForTest(host) { + _overlayHost = host || DEFAULT_OVERLAY_HOST; + }, + }; + + return directionsLayer; +} diff --git a/src/routeCinematics.test.mjs b/src/routeCinematics.test.mjs index e74b0a741..fa3e9d823 100644 --- a/src/routeCinematics.test.mjs +++ b/src/routeCinematics.test.mjs @@ -18,8 +18,11 @@ import { createRouteFlight, flyRoute, getActiveCameraMotion, + activeCameraMotionId, initCameraVerbs, interruptCameraMotion, + interruptCameraMotionIfActive, + moveCamera, prefersReducedMotion, routeAltitudeOffsetM, routeColdSeedFloorM, @@ -1000,3 +1003,78 @@ test('the 0.5 s duration floor is the one place the speed word is not the mean', const normal = flightFrom(TWO_TURN_ROUTE); assert.ok(Math.abs((normal.totalM / normal.durationS) - CRUISE_M_S.normal) < 1e-9); }); + +test('a flight can be stopped by its owner, and only while it is still theirs', () => { + // CLEAR in the Directions row must stop the flight IT started. By then the + // camera may belong to someone else — the user grabbed it, voice flew + // somewhere, a tracked aircraft took over — and stopping that would be a + // feature reaching across the app to cancel a stranger's motion. + const viewer = createTickableViewer(); + initCameraVerbs(viewer.viewer, () => null); + const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })); + + assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id'); + const first = flyRoute([{ type: 'route', label: 'mine', path }]); + assert.equal(first.ok, true); + assert.ok(first.motionId > 0, 'a started flight reports its id'); + assert.equal(activeCameraMotionId(), first.motionId); + assert.equal(getActiveCameraMotion().motionId, first.motionId); + + // Someone else replaces it. The first owner's cancel must now be inert. + const second = flyRoute([{ type: 'route', label: 'theirs', path }]); + assert.ok(second.motionId > first.motionId, 'ids are monotonic'); + assert.equal( + interruptCameraMotionIfActive(first.motionId, 'stale-owner').wasActive, + false, + ); + assert.equal( + activeCameraMotionId(), + second.motionId, + 'the newer flight is still running', + ); + + // The current owner stops its own flight. + assert.equal( + interruptCameraMotionIfActive(second.motionId, 'owner-cancel').wasActive, + true, + ); + assert.equal(activeCameraMotionId(), 0); + assert.equal(getActiveCameraMotion(), null); + + // A second cancel, and a cancel with no id at all, stop nothing. + assert.equal(interruptCameraMotionIfActive(second.motionId).wasActive, false); + assert.equal(interruptCameraMotionIfActive(0).wasActive, false); + viewer.restore(); +}); + +test('a new viewer lifetime drops the previous view-target getter', () => { + // The getter closes over the viewer it was built for. Re-initialising for a + // new viewer without one must not leave the old callback answering. + const first = createTickableViewer(); + const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0); + let asked = 0; + initCameraVerbs(first.viewer, () => { asked += 1; return target; }); + const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' }); + assert.equal(orbit.ok, true); + assert.equal(orbit.armed, undefined, 'the installed getter supplied a target'); + assert.ok(asked > 0); + interruptCameraMotion('test-cleanup'); + + // Same viewer, no getter: the installed one stays. + initCameraVerbs(first.viewer); + const again = moveCamera({ motion: 'orbit', mode: 'continuous' }); + assert.equal(again.armed, undefined, 'the getter survives a call without one'); + interruptCameraMotion('test-cleanup'); + first.restore(); + + // New viewer, no getter: the old one is gone, so the orbit arms instead of + // capturing a target from a viewer that no longer exists. + const second = createTickableViewer(); + initCameraVerbs(second.viewer); + const askedBefore = asked; + const armed = moveCamera({ motion: 'orbit', mode: 'continuous' }); + assert.equal(armed.armed, 'waiting-for-arrival'); + assert.equal(asked, askedBefore, 'the old getter was never called again'); + interruptCameraMotion('test-cleanup'); + second.restore(); +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 1580c0fd1..78d311369 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -4,6 +4,14 @@ import { googlePlacesContextProxy, installRouteMiddleware, } from '../../server/providers/places.js'; +import { + ROUTE_UPSTREAM_MIN_INTERVAL_MS, + ROUTE_UPSTREAM_QUEUE_MAX, + _resetRouteUpstreamForTest, + _routeInflightCountForTest, + _setRouteUpstreamIntervalForTest, +} from '../../server/providers/places/routes.js'; +import { ROUTE_STEPS_MAX } from '../../src/data/routeSteps.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -226,3 +234,251 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be ); assert.equal((await request(route, query)).body.error, 'no route found'); }); + +/** One OSRM route with `count` maneuvers, for the step tests. */ +function osrmRouteWithSteps(count) { + return { + code: 'Ok', + routes: [ + { + distance: 123.6, + duration: 80.2, + geometry: { + coordinates: [ + [-97, 30], + [-97.01, 30.01], + ], + }, + legs: [ + { + steps: Array.from({ length: count }, (_, i) => ({ + name: `Road ${i}`, + distance: 10, + duration: 5, + maneuver: { + type: i === 0 ? 'depart' : 'turn', + modifier: 'left', + location: [-97 + i / 1000, 30], + }, + })), + }, + ], + }, + ], + }; +} + +test('steps are opt-in: the upstream is only asked for them when a caller is', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + const asked = []; + t.mock.method(globalThis, 'fetch', async (url) => { + asked.push(String(url)); + return Response.json(osrmRouteWithSteps(3)); + }); + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01'; + + // The voice route annotation and fly_route do not read maneuvers. What they + // get back must be exactly what this endpoint returned before steps existed. + const plain = await request(route, query); + assert.deepEqual(plain.body, { + ok: true, + profile: 'car', + distanceM: 124, + durationS: 80, + geometry: [ + [-97, 30], + [-97.01, 30.01], + ], + }); + assert.equal('steps' in plain.body, false, 'no steps key at all'); + assert.match(asked[0], /steps=false/, 'the upstream payload stays small'); + + // Directions asks, and gets them. + _resetRouteUpstreamForTest(); + const withSteps = await request(route, `${query}&steps=1`); + assert.equal(asked.length, 2); + assert.match(asked[1], /steps=true/); + assert.equal(withSteps.body.steps.length, 3); + assert.equal(withSteps.body.steps[0].instruction, 'Head out on Road 0'); + assert.equal(withSteps.body.steps[1].instruction, 'Turn left onto Road 1'); + + // A stepful cache entry still serves a stepless caller its own shape. + const reuse = await request(route, query); + assert.equal(asked.length, 2, 'served from cache'); + assert.deepEqual( + reuse.body, + plain.body, + 'byte-identical to the no-steps shape', + ); +}); + +test('identical concurrent route requests make one upstream call', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + let calls = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + await gate; + return Response.json(osrmRouteWithSteps(4)); + }); + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01&steps=1'; + + // Three rapid reroutes of the same A→B, before any of them answers. + const all = [ + request(route, query), + request(route, query), + request(route, query), + ]; + // ...plus a caller that wants no steps; it rides the same call. + all.push(request(route, '?profile=car&coords=-97,30;-97.01,30.01')); + await Promise.resolve(); + assert.equal( + _routeInflightCountForTest(), + 1, + 'one upstream call is in flight', + ); + release(); + const results = await Promise.all(all); + assert.equal(calls, 1, 'the FOSSGIS servers saw one request, not four'); + assert.equal(results[0].body.steps.length, 4); + assert.deepEqual(results[0].body, results[1].body); + assert.deepEqual(results[0].body, results[2].body); + assert.equal( + 'steps' in results[3].body, + false, + 'the stepless caller still gets no steps', + ); + assert.equal( + _routeInflightCountForTest(), + 0, + 'the in-flight entry is released', + ); +}); + +test('a pathological maneuver count is bounded before it reaches the browser', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => + Response.json(osrmRouteWithSteps(5000)), + ); + const result = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01&steps=1', + ); + assert.equal(result.body.steps.length, ROUTE_STEPS_MAX); + assert.ok(ROUTE_STEPS_MAX <= 200, 'the cap stays a cap'); +}); + +test('outbound route requests are spaced to the rate the routing service asks for', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + const sentAt = []; + t.mock.method(globalThis, 'fetch', async (url) => { + sentAt.push({ at: Date.now(), url: String(url) }); + return Response.json(osrmRouteWithSteps(2)); + }); + // FOSSGIS publish "one request per second max". The per-client limiter alone + // cannot honour that: a DRIVE and a WALK of the same trip are two different + // cache keys and used to leave 48 ms apart. + assert.ok(ROUTE_UPSTREAM_MIN_INTERVAL_MS >= 1000); + const first = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + ); + assert.equal(first.body.ok, true); + assert.equal(sentAt.length, 1); + + const second = request( + '/api/route', + '?profile=foot&coords=-97,30;-97.01,30.01', + ); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(sentAt.length, 1, 'the second profile waits for its slot'); + assert.equal((await second).body.ok, true); + assert.ok( + sentAt[1].at - sentAt[0].at >= ROUTE_UPSTREAM_MIN_INTERVAL_MS - 20, + `two outbound calls ${sentAt[1].at - sentAt[0].at} ms apart`, + ); + // A cached route costs no outbound slot at all. + const cached = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + ); + assert.equal(cached.body.ok, true); + assert.equal(sentAt.length, 2, 'the cache answers without an upstream call'); +}); + +test('past the outbound queue depth the answer is an honest 429, not a growing queue', async (t) => { + _resetRouteUpstreamForTest(); + // The real gate is one per second (asserted above); this test is about what + // happens past the queue depth, so it runs the gate fast. + _setRouteUpstreamIntervalForTest(5); + t.after(() => _resetRouteUpstreamForTest()); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => + Response.json(osrmRouteWithSteps(2)), + ); + const pending = []; + for (let i = 0; i < ROUTE_UPSTREAM_QUEUE_MAX + 3; i += 1) { + pending.push( + request( + '/api/route', + `?profile=car&coords=-97,${30 + i / 100};-97.01,30.01`, + ), + ); + } + const results = await Promise.all(pending); + const refused = results.filter((result) => result.statusCode === 429); + assert.ok(refused.length >= 1, 'the excess is refused rather than queued'); + assert.match(refused[0].body.error, /routing busy/); + assert.ok( + Number(refused[0].headers['retry-after']) >= 1, + `Retry-After is ${refused[0].headers['retry-after']}`, + ); + // Everything that was admitted still got a real answer. + assert.ok(results.some((result) => result.body.ok === true)); +}); + +test('an upstream rate limit is reported as one, not as "no route found"', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + t.mock.method( + globalThis, + 'fetch', + async () => + new Response('slow down', { + status: 429, + headers: { 'retry-after': '30', 'content-type': 'text/plain' }, + }), + ); + const result = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + ); + assert.equal(result.statusCode, 429); + assert.match(result.body.error, /routing service is rate limited/); + assert.equal( + result.headers['retry-after'], + '30', + "the service's own wait is passed on", + ); +}); + +test('the upstream host is pinned: a redirect is refused, not followed', async (t) => { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + seen.push(options?.redirect); + return Response.json(osrmRouteWithSteps(2)); + }); + await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01'); + assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index a9b81e2ba..08ea58f8a 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -265,6 +265,7 @@ export class StyleManager { // Bloom/sharpen state this._globeResetPromise = null; this._dataManager = null; + this._directionsShellModule = null; this._windowResizeHandler = null; this._cctvRequestFocusHandler = null; @@ -929,6 +930,34 @@ export class StyleManager { return this._runExplicitNavigation(noun, navigate, releaseOptions); } + /** + * Hand the Directions layer the camera seams its FLY chip needs: the same + * immediate-navigation facade voice route flights go through, so there is + * one camera owner rather than a second one inside a data layer, the shared + * ground-floor read/warm the route dolly flies over, and the app's own toast + * so the layer can speak where the rest of the UI speaks. + * @returns {void} + */ + _connectDirectionsCamera() { + if (!this._dataManager) { + // Detaching: the layer outlives this shell, so it must not keep calling + // a facade whose viewer is going away. + this._directionsShellModule?.attachShellServices?.(null); + this._directionsShellModule = null; + return; + } + const directions = this._dataManager.layers?.get('directions')?.module; + if (typeof directions?.attachShellServices !== 'function') return; + this._directionsShellModule = directions; + directions.attachShellServices({ + runNavigation: (navigate) => + this.runImmediateNavigation('route', navigate), + floorFn: (lat, lon) => this.services.cachedGroundFloor(lat, lon), + warmFn: (cells) => this.services.warmGroundFloor(cells), + showToast: (message) => this._showToast(message), + }); + } + /** Supersede deferred work when an owner-specific route handles release. */ supersedeDeferredNavigation() { return this._stampNavigation(); @@ -1654,6 +1683,7 @@ export class StyleManager { this._syncContextModeButtons(); this._cctvControls.connect(); this._radioControls.connect(); + this._connectDirectionsCamera(); if (!this._awarenessSelectedHandler) { this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false); @@ -3683,6 +3713,8 @@ export class StyleManager { this._contextControls.disconnect(); this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; + this._directionsShellModule?.attachShellServices?.(null); + this._directionsShellModule = null; if (this._windowResizeHandler) { window.removeEventListener('resize', this._windowResizeHandler); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index fafcb5d67..51142ed19 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -206,7 +206,23 @@ export class LayerPanel { this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); row.appendChild(controls); - this._syncRowControls(controls, layer); + // An ordered list below the chips, for a layer whose row carries a + // sequence (turn-by-turn directions). Its own delegated listener, its + // own container — the chip row stays a chip row. + const list = document.createElement('ol'); + list.className = 'data-row-list'; + list.hidden = true; + this._bind(list, 'click', (event) => { + const button = event.target?.closest?.('.data-row-list-item'); + if (!button || button.disabled) return; + const item = this._rowControlsFor(layer.id)?.list?.items?.find( + (entry) => entry.id === button.dataset.listItemId, + ); + if (item?.params) + this.setLayerParams(layer.id, item.params, { origin: 'user' }); + }); + row.appendChild(list); + this._syncRowControls(controls, layer, list); } this._toggleContainer.appendChild(row); @@ -232,12 +248,14 @@ export class LayerPanel { * are replaced freely. * @param {HTMLElement|null} container The row's `.data-toggle-controls` node. * @param {object} layer Registered layer entry. + * @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node. */ - _syncRowControls(container, layer) { + _syncRowControls(container, layer, listContainer = null) { if (!container) return; const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; const chips = controls?.chips || []; const legend = controls?.legend || []; + this._syncRowList(listContainer, controls?.list || null); container.hidden = chips.length === 0 && legend.length === 0; for (const node of [...container.children]) { @@ -285,6 +303,84 @@ export class LayerPanel { } } + /** + * Render a row's ordered list (turn-by-turn directions). + * + * Each entry is a real `