Files
gods-eye-view/src/layers/directions/index.js
T
German KhokhlovandClaude Opus 5.5 2f12cbf5c8 Merge remote-tracking branch 'origin/main' into german/hal-639-de
Conflicts in es, ru, zh-CN and zh-Hant catalogs: both sides appended keys
at the end (cyclone errors from this branch, routing refusals from main);
kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:12:08 +04:00

1754 lines
59 KiB
JavaScript

/**
* @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 { roadLabel } from '../../data/routeSteps.js';
import {
t,
msg,
formatNumber,
getLocale,
translateKnown,
} from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.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: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
});
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The row error for a failed route request (createDirectionsSource errors),
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
* in src/ui/statusText.js).
*/
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
`${error.refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
return `Routing unavailable (HTTP ${error.status})`;
return error?.message || msg('Routing unavailable');
}
/** 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;
// ---------------------------------------------------------------------------
// Display text in the page's language. The proxy's English `instruction` stays
// the machine value; steps are phrased from their OSRM type/modifier/road.
// ---------------------------------------------------------------------------
/**
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
* in src/data/routeSteps.js); number and unit never break apart.
* @param {number} meters
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
}
/**
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
* @param {number} seconds
* @returns {string}
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
const wholeSeconds = Math.round(seconds);
if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
function directionWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'direction' });
case 'right':
return t('right', { context: 'direction' });
case 'slight left':
return t('slightly left', { context: 'direction' });
case 'slight right':
return t('slightly right', { context: 'direction' });
case 'sharp left':
return t('sharply left', { context: 'direction' });
case 'sharp right':
return t('sharply right', { context: 'direction' });
case 'straight':
return t('straight', { context: 'direction' });
case 'uturn':
return t('around', { context: 'direction' });
default:
return modifier;
}
}
/**
* The side to keep at a fork. English reuses the turn words; a language may
* need another form here ("держитесь левее", not "держитесь налево").
*/
function forkWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'fork direction' });
case 'right':
return t('right', { context: 'fork direction' });
case 'slight left':
return t('slightly left', { context: 'fork direction' });
case 'slight right':
return t('slightly right', { context: 'fork direction' });
case 'sharp left':
return t('sharply left', { context: 'fork direction' });
case 'sharp right':
return t('sharply right', { context: 'fork direction' });
case 'straight':
return t('straight', { context: 'fork direction' });
default:
return directionWord(modifier);
}
}
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
function laneWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'lane direction' });
case 'right':
return t('right', { context: 'lane direction' });
case 'slight left':
return t('slightly left', { context: 'lane direction' });
case 'slight right':
return t('slightly right', { context: 'lane direction' });
case 'sharp left':
return t('sharply left', { context: 'lane direction' });
case 'sharp right':
return t('sharply right', { context: 'lane direction' });
case 'straight':
return t('straight', { context: 'lane direction' });
default:
return directionWord(modifier);
}
}
/** "1st", "2nd", … for a roundabout exit. */
function exitOrdinal(exit) {
const number = formatNumber(exit);
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
case 'one':
return t('{number}st', { context: 'ordinal', number });
case 'two':
return t('{number}nd', { context: 'ordinal', number });
case 'few':
return t('{number}rd', { context: 'ordinal', number });
default:
return t('{number}th', { context: 'ordinal', number });
}
}
/**
* One maneuver in the page's language, from the step's OSRM type, modifier,
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
* word for word. A step without a type (an older payload) keeps the proxy's
* English instruction.
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
* @returns {string}
*/
export function directionsInstruction(step) {
const type =
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
if (!type)
return typeof step?.instruction === 'string' ? step.instruction : '';
const modifier =
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
const road = roadLabel(step);
const direction = directionWord(modifier);
const values = { road, direction };
switch (type) {
case 'depart':
return road ? t('Head out on {road}', values) : t('Head out');
case 'arrive':
if (modifier === 'left')
return t('Arrive at your destination, on the left');
if (modifier === 'right')
return t('Arrive at your destination, on the right');
return t('Arrive at your destination');
case 'turn':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (modifier === 'straight')
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
if (direction)
return road
? t('Turn {direction} onto {road}', values)
: t('Turn {direction}', values);
return road
? t('Turn onto {road}', values)
: t('Turn', { context: 'maneuver' });
case 'continue':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (direction && modifier !== 'straight')
return road
? t('Continue {direction} onto {road}', values)
: t('Continue {direction}', values);
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
case 'end of road':
if (direction)
return road
? t('At the end of the road, turn {direction} onto {road}', values)
: t('At the end of the road, turn {direction}', values);
return road
? t('At the end of the road, continue onto {road}', values)
: t('At the end of the road, continue');
case 'fork':
if (direction) {
const fork = { road, direction: forkWord(modifier) };
return road
? t('Keep {direction} at the fork onto {road}', fork)
: t('Keep {direction} at the fork', fork);
}
return road
? t('Continue at the fork onto {road}', values)
: t('Continue at the fork');
case 'merge':
if (direction)
return road
? t('Merge {direction} onto {road}', values)
: t('Merge {direction}', values);
return road
? t('Merge onto {road}', values)
: t('Merge', { context: 'maneuver' });
case 'on ramp':
if (modifier === 'left' || modifier === 'right')
return road
? t('Take the ramp on the {direction} onto {road}', values)
: t('Take the ramp on the {direction}', values);
if (direction && modifier !== 'straight')
return road
? t('Take the ramp {direction} onto {road}', values)
: t('Take the ramp {direction}', values);
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
case 'off ramp':
if (modifier === 'left' || modifier === 'right')
return road
? t('Take the exit on the {direction} onto {road}', values)
: t('Take the exit on the {direction}', values);
if (direction && modifier !== 'straight')
return road
? t('Take the exit {direction} onto {road}', values)
: t('Take the exit {direction}', values);
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
case 'roundabout':
case 'rotary':
if (Number.isInteger(step.exit) && step.exit >= 1) {
const exit = exitOrdinal(step.exit);
return road
? t('At the roundabout, take the {exit} exit onto {road}', {
exit,
road,
})
: t('At the roundabout, take the {exit} exit', { exit });
}
return road
? t('Enter the roundabout onto {road}', values)
: t('Enter the roundabout');
case 'roundabout turn':
if (direction)
return road
? t('At the roundabout, turn {direction} onto {road}', values)
: t('At the roundabout, turn {direction}', values);
return road
? t('At the roundabout, continue onto {road}', values)
: t('At the roundabout, continue');
case 'exit roundabout':
case 'exit rotary':
return road
? t('Exit the roundabout onto {road}', values)
: t('Exit the roundabout');
case 'use lane': {
const side = modifier.includes('left')
? 'left'
: modifier.includes('right')
? 'right'
: '';
if (side) {
const lane = { road, direction: laneWord(side) };
return road
? t('Use the {direction} lane onto {road}', lane)
: t('Use the {direction} lane', lane);
}
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
default:
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
}
// ---------------------------------------------------------------------------
// 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;
}
/**
* A travel-mode word inside a sentence: lower case, except in German, where
* the words are nouns and keep their capital ("Auto", "Fahrrad").
* @param {string} word Translated mode word.
* @returns {string}
*/
function modeWordInSentence(word) {
return getLocale() === 'de' ? word : word.toLowerCase();
}
/**
* 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: t(spec.chip),
active: mode === id,
state: mode === id ? 'active' : 'idle',
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: modeWordInSentence(t(spec.word)),
}),
params: { mode: id },
}));
chips.push({
id: 'set-a',
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
active: armed === 'a',
state: armed === 'a' ? 'active' : 'idle',
title:
armed === 'a'
? t('Click a spot on the globe to place A (click again to cancel)')
: t('Then click the globe to place the start'),
params: { arm: armed === 'a' ? null : 'a' },
});
chips.push({
id: 'set-b',
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
active: armed === 'b',
state: armed === 'b' ? 'active' : 'idle',
title:
armed === 'b'
? t('Click a spot on the globe to place B (click again to cancel)')
: t('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: t('Swap A and B'),
params: { swap: true },
});
const flying = Number.isInteger(flightStep);
chips.push({
id: 'fly',
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
disabled: !route || routing,
busy: routing || flying,
active: flying,
state: routing || flying ? 'loading' : 'idle',
title: route
? t('Fly the camera along the route')
: t('Place A and B first'),
params: { fly: true },
});
chips.push({
id: 'clear',
label: t('CLEAR', { context: 'action' }),
disabled: !a && !b && !route,
state: 'idle',
title: t('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: msg('Draw'),
leave: msg('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];
if (!known)
return t('{name} is active — turn {name} off, then set {endpoint}', {
name: translateKnown(pointerOwnerName(id)),
endpoint,
});
return t('{name} is active — {leave}, then set {endpoint}', {
name: t(known.name),
leave: t(known.leave),
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: routeDistanceText(step.distanceM) || '—',
text: directionsInstruction(step),
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: t('Only the first {count} turns of this route are shown', {
count: steps.length,
}),
active: false,
current: false,
disabled: true,
});
}
return { ariaLabel: t('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'; // English in layer state; the layer row translates it
if (pointerBlocked) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: msg(
'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: t('Routing…'),
source,
};
}
if (status === 'error') {
return {
count: 0,
lastUpdate,
error: error || msg('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 spec = DIRECTIONS_MODES[mode];
const word = spec ? t(spec.word) : mode;
const values = {
distance: routeDistanceText(route.distanceM),
duration: routeDurationText(route.durationS),
mode: word,
count: route.steps.length,
};
const summary = route.stepsTruncated
? t('{distance} · {duration} · {mode} · first {count} turns', values)
: t('{distance} · {duration} · {mode}', values);
return {
count: route.steps.length,
lastUpdate,
error: null,
source,
coverage: summary,
loadingLabel: summary,
};
}
let coverage;
if (armed)
coverage = t('Click the globe to place {endpoint}', {
endpoint: armed.toUpperCase(),
});
else if (a && !b) coverage = t('SET B, then click the globe');
else if (!a && b) coverage = t('SET A, then click the globe');
else coverage = t('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(routeDistanceText(step.distanceM));
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
const position = { step: index + 1, total: steps.length };
details.push(
leg.length
? t('Step {step} of {total} · then {leg}', {
...position,
leg: leg.join(' · '),
})
: t('Step {step} of {total}', position),
);
const next = steps[index + 1];
if (next)
details.push(
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
);
return { title: directionsInstruction(step), 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 =
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
payload?.error && payload.error !== 'no route found'
? `Routing failed: ${payload.error}`
: msg('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'
? msg('Routing timed out')
: error?.name === 'TypeError'
? msg('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} 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: t('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;
}