/** * @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 { directionsRequestUrl } from './source.js'; /** * 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, source: {getRoute: Function}}} options * @returns {object} The layer module the data manager registers. */ export function createDirectionsLayer({ services, source }) { if (typeof source?.getRoute !== 'function') throw new TypeError('Directions require a route source'); /** * 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 cartesian = services.scenePick.pickGroundPosition( _viewer, screenPosition, ); if (!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() { services.credits?.hideOsmCredit?.(_viewer, 'directions'); _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.credits?.showOsmCredit?.(_viewer, 'directions'); 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 payload = await source.getRoute( { mode, a: _a, b: _b }, { signal: controller.signal, }, ); 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; }