Merge branch 'main' into feat/alpr-visual-treatment

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-14 18:13:10 -07:00
commit 27dec4e949
31 files changed
+4913 -65

No files matched your search

+2 -2
View File
@@ -336,7 +336,7 @@ let _flowFabricRegistered = false;
/** Register the GevRouteFlow fabric ONCE so Cesium's `Material.fromType('GevRouteFlow')`
* can build the material the render pipeline uses. Constructing one Material with the
* fabric caches it under its type name. */
function ensureFlowFabricRegistered() {
export function ensureFlowFabricRegistered() {
if (_flowFabricRegistered) return;
makeRouteFlowMaterial('#ffffff'); // side effect: registers the 'GevRouteFlow' type
_flowFabricRegistered = true;
@@ -352,7 +352,7 @@ function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
this._definitionChanged = new Cesium.Event();
}
+2
View File
@@ -8,6 +8,7 @@ import { createApplicationCctv } from './layers/cctv.js';
import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
@@ -109,6 +110,7 @@ export function createApplicationCatalog({
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
vessels,
installations,
createApplicationAwareness({
+1 -1
View File
@@ -38,7 +38,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 17);
assert.equal(first.layers.length, 18);
assert.deepEqual(
first.layers.map(({ id }) => id),
second.layers.map(({ id }) => id),
+12
View File
@@ -0,0 +1,12 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
}
+72 -11
View File
@@ -805,9 +805,49 @@ export function advanceRouteFlight(state, dt) {
let _viewer = null;
let _getTarget = null;
let _active = null; // { kind, motion, direction, speed, mode, ...state }
let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state }
let _tickRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
/**
* Install a motion and stamp it with a fresh id. The id is what lets a caller
* that started a flight stop THAT flight later without stopping whatever has
* replaced it in the meantime.
* @param {object|null} state Motion state, or null to clear.
* @returns {object|null} The installed state.
*/
function setActiveMotion(state) {
if (state) {
_motionSeq += 1;
state.motionId = _motionSeq;
}
_active = state;
return state;
}
/**
* @returns {number} Id of the running motion, or 0 when the camera is idle.
*/
export function activeCameraMotionId() {
return _active?.motionId || 0;
}
/**
* Stop a motion only while it is still the one running. A feature that owns a
* flight calls this on clear/teardown: if the user (or voice, or a tracked
* entity) has since taken the camera, the newer motion is left alone.
* @param {number} motionId Id returned when the motion started.
* @param {string} [reason] Diagnostic label.
* @returns {{wasActive: boolean, reason: string, leveled: boolean}}
*/
export function interruptCameraMotionIfActive(motionId, reason = 'owner-cancel') {
if (!motionId || _active?.motionId !== motionId) {
return { wasActive: false, reason, leveled: false };
}
return interruptCameraMotion(reason);
}
function clearLookAt() {
try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } catch { /* teardown race */ }
@@ -856,7 +896,10 @@ export function interruptCameraMotion(reason = 'interrupt') {
/** Diagnostics / harness hook. */
export function getActiveCameraMotion() {
if (!_active) return null;
const info = { kind: _active.kind, mode: _active.mode, speed: _active.speed };
const info = {
kind: _active.kind, mode: _active.mode, speed: _active.speed,
motionId: _active.motionId || 0,
};
if (_active.kind === 'route') {
info.progress = _active.u;
info.bankDeg = _active.bankDeg;
@@ -968,11 +1011,26 @@ function onTick() {
}
}
/** Wire the module to the viewer once (idempotent). */
export function initCameraVerbs(viewer, getViewTargetCartesian) {
if (_viewer === viewer) return;
/**
* Wire the module to the viewer once (idempotent). The view-target getter is
* optional so a non-voice caller (the Directions FLY chip) can arm route
* flights before any voice session exists; a later call with a real getter
* installs it, and a call without one, FOR THE SAME VIEWER, never clears an
* installed getter.
*
* A getter closes over the viewer it was built for. When the viewer changes —
* a new application lifetime — the previous getter is dropped even if this
* call brings none, so a callback from the old viewer can never answer for the
* new one.
*/
export function initCameraVerbs(viewer, getViewTargetCartesian = null) {
const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null;
if (_viewer === viewer) {
if (nextTarget) _getTarget = nextTarget;
return;
}
_getTarget = nextTarget;
_viewer = viewer;
_getTarget = getViewTargetCartesian;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
for (const rm of _inputRemovers) rm();
@@ -1045,7 +1103,7 @@ export function moveCamera(args = {}, runNavigation = null) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
// and capture at the destination; a target existing right now is
// meaningless (the globe is always under the crosshair).
_active = { kind: 'orbit', direction: direction || 'right', speed, mode, pending: true };
setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true });
holdContinuousRender('camera-verb');
return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' };
}
@@ -1053,7 +1111,7 @@ export function moveCamera(args = {}, runNavigation = null) {
if (!target) {
// Mid-flight chain ("fly to X and orbit it"): ARM the orbit — the tick
// loop keeps trying for a ground target as the flight settles.
_active = { kind: 'orbit', direction: direction || 'right', speed, mode, pending: true };
setActiveMotion({ kind: 'orbit', direction: direction || 'right', speed, mode, pending: true });
holdContinuousRender('camera-verb');
return { ok: true, action: 'move_camera', motion, direction: direction || 'right', speed, mode, armed: 'waiting-for-arrival' };
}
@@ -1068,7 +1126,7 @@ export function moveCamera(args = {}, runNavigation = null) {
state.hpr = new Cesium.HeadingPitchRange(cam.heading, pitch, range);
state.hprStartHeading = cam.heading;
}
_active = state;
setActiveMotion(state);
// Verb motion drives the camera per clock tick — hold until interrupted;
// interruptCameraMotion is the single release path. (perf wave 2)
holdContinuousRender('camera-verb');
@@ -1136,7 +1194,7 @@ export function flyRoute(annoList, args = {}, floorFn = null, runNavigation = nu
}
const start = () => {
interruptCameraMotion('replaced');
_active = createRouteFlight({
setActiveMotion(createRouteFlight({
pts,
cumM,
speed,
@@ -1145,12 +1203,15 @@ export function flyRoute(annoList, args = {}, floorFn = null, runNavigation = nu
probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells),
cameraHeightM: _viewer?.camera?.positionCartographic?.height,
reducedMotion: prefersReducedMotion(),
});
}));
holdContinuousRender('camera-verb');
return {
ok: true, action: 'fly_route', label: route.label || null, speed,
distanceM: Math.round(_active.totalM), durationS: Math.round(_active.durationS),
waypoints: pts.length,
// The caller keeps this so it can stop ITS flight later without stopping
// whatever has replaced it (see interruptCameraMotionIfActive).
motionId: _active.motionId,
};
};
return typeof runNavigation === 'function' ? runNavigation(start) : start();
+10
View File
@@ -173,6 +173,16 @@ export const DATA_CREDITS = [
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
},
{
key: 'osrm-routing',
// The service asks for its attribution to carry a "fix the map" link, so
// a reader who spots a wrong turn can go and correct the data it came from.
html:
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
},
{
key: 'radio-browser',
html:
+69
View File
@@ -0,0 +1,69 @@
/**
* @module directions
* @description Service bundle for the Directions layer.
*
* The layer itself lives in `src/layers/directions/`, which imports nothing
* but Cesium and the pure step formatter. This module is the one place that
* names the application's shared services — the render governor, sprite order,
* the pick registry, the world overlay, the annotation material, the camera
* verbs, the shared ground floor and the pointer arbiter — and it re-exports
* the layer's pure helpers for the tests and the share-link code.
*/
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as scenePick from './scenePick.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import * as camera from '../cameraVerbs.js';
import * as ground from './groundFloor.js';
import * as input from './inputOwnership.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
/** The shared scene owners every Directions instance runs on. */
export const directionsServices = Object.freeze({
render,
sprites,
picking,
scenePick,
overlays,
annotations,
camera,
ground,
input,
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
}
export {
DEFAULT_DIRECTIONS_MODE,
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
FLIGHT_PROGRESS_MS,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
createDirectionsLayer,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
directionsStats,
directionsStepCopy,
directionsStepList,
normalizeDirectionsParams,
normalizeRoutePayload,
pointerBlockedMessage,
stepAnchorDelayMs,
stepIndexAtDistance,
stepMarkerHeightM,
stepMarkerIndices,
} from '../layers/directions/index.js';
+775
View File
@@ -0,0 +1,775 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
DEFAULT_DIRECTIONS_MODE,
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
directionsStats,
directionsStepCopy,
directionsStepList,
normalizeDirectionsParams,
normalizeRoutePayload,
stepAnchorDelayMs,
stepIndexAtDistance,
stepMarkerHeightM,
stepMarkerIndices,
pointerBlockedMessage,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import {
claimPointer,
isPointerFree,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from './inputOwnership.js';
/** Take the pointer as some other tool, and hand back its release. */
function claimAs(owner) {
const lease = claimPointer(owner);
assert.ok(lease, `${owner} should have taken the pointer`);
return () => releasePointer(lease);
}
import { LAYER_STATE_REGISTRY } from './layerState.js';
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
/** The instance under test; each ownership fixture builds a fresh one. */
let directionsLayer = createApplicationDirectionsLayer();
const idle = {
enabled: true,
mode: 'car',
armed: null,
a: null,
b: null,
status: 'idle',
error: null,
route: null,
lastUpdate: null,
};
const A = { lat: 60.1699, lon: 24.9384 };
const B = { lat: 60.2055, lon: 24.6559 };
const route = {
distanceM: 21479,
durationS: 1478,
geometry: [
[24.9384, 60.1699],
[24.6559, 60.2055],
],
steps: [
{
index: 0,
instruction: 'Head out on Kaivokatu',
distanceM: 8,
durationS: 1,
lat: 60.1699,
lon: 24.9384,
},
{
index: 1,
instruction: 'Turn right onto Kansakoulukatu',
distanceM: 236,
durationS: 39,
lat: 60.1683,
lon: 24.9349,
},
{
index: 2,
instruction: 'Arrive at your destination',
distanceM: 0,
durationS: 0,
lat: 60.2055,
lon: 24.6559,
},
],
mode: 'car',
};
test('layer module declares the manager contract, params, and row controls', () => {
assert.equal(directionsLayer.id, 'directions');
assert.equal(directionsLayer.updateInterval, 0);
for (const method of [
'init',
'enable',
'disable',
'update',
'getStats',
'destroy',
'setParams',
'getParams',
'getRowControls',
'setRowControlsListener',
'attachDataManager',
]) {
assert.equal(
typeof directionsLayer[method],
'function',
`${method} is implemented`,
);
}
assert.deepEqual(directionsLayer.getParams(), {
mode: DEFAULT_DIRECTIONS_MODE,
});
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'directions');
assert.ok(entry, 'directions has a share-link token');
assert.equal(
LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length,
1,
);
assert.ok(SPRITE_LAYER_ORDER.includes('directions'));
});
test('params: unknown modes are rejected, commands and arm state are normalized', () => {
assert.deepEqual(normalizeDirectionsParams({ mode: 'FOOT' }), {
mode: 'foot',
});
assert.equal(normalizeDirectionsParams({ mode: 'rocket' }), null);
assert.deepEqual(normalizeDirectionsParams({ arm: 'a' }), { arm: 'a' });
assert.deepEqual(normalizeDirectionsParams({ arm: 'zzz' }), { arm: null });
assert.deepEqual(
normalizeDirectionsParams({ swap: true, fly: true, clear: true }),
{ swap: true, fly: true, clear: true },
);
assert.deepEqual(normalizeDirectionsParams({ swap: 'yes' }), {});
assert.deepEqual(normalizeDirectionsParams(), {});
// The live module refuses an unknown mode without touching state.
assert.equal(directionsLayer.setParams({ mode: 'rocket' }), false);
assert.deepEqual(directionsLayer.getParams(), {
mode: DEFAULT_DIRECTIONS_MODE,
});
});
test('row chips: modes, arming, and command availability follow the state', () => {
const { chips } = directionsRowControls(idle);
const ids = chips.map((c) => c.id);
assert.deepEqual(ids, [
'mode-car',
'mode-foot',
'mode-bike',
'set-a',
'set-b',
'swap',
'fly',
'clear',
]);
assert.equal(chips.find((c) => c.id === 'mode-car').active, true);
assert.equal(chips.find((c) => c.id === 'set-a').label, 'SET A');
assert.deepEqual(chips.find((c) => c.id === 'set-a').params, { arm: 'a' });
assert.equal(chips.find((c) => c.id === 'fly').disabled, true);
assert.equal(chips.find((c) => c.id === 'swap').disabled, true);
assert.equal(chips.find((c) => c.id === 'clear').disabled, true);
const armed = directionsRowControls({ ...idle, armed: 'a' }).chips;
assert.equal(armed.find((c) => c.id === 'set-a').label, 'CLICK MAP');
assert.deepEqual(
armed.find((c) => c.id === 'set-a').params,
{ arm: null },
'clicking again cancels',
);
const ready = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
}).chips;
assert.equal(ready.find((c) => c.id === 'set-a').label, 'A ✓');
assert.equal(ready.find((c) => c.id === 'fly').disabled, false);
assert.equal(ready.find((c) => c.id === 'swap').disabled, false);
assert.equal(ready.find((c) => c.id === 'clear').disabled, false);
const routing = directionsRowControls({
...idle,
a: A,
b: B,
status: 'routing',
}).chips;
assert.equal(routing.find((c) => c.id === 'fly').busy, true);
for (const mode of Object.keys(DIRECTIONS_MODES))
assert.ok(ids.includes(`mode-${mode}`));
});
test('stats guide the user, show progress, and summarize a route honestly', () => {
assert.equal(directionsStats(idle).coverage, 'SET A, then click the globe');
assert.equal(
directionsStats({ ...idle, armed: 'b' }).coverage,
'Click the globe to place B',
);
assert.equal(
directionsStats({ ...idle, a: A }).coverage,
'SET B, then click the globe',
);
const routing = directionsStats({ ...idle, a: A, b: B, status: 'routing' });
assert.equal(routing.loading, true);
const ready = directionsStats({
...idle,
a: A,
b: B,
status: 'ready',
route,
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
assert.equal(
directionsStats(idle).loadingLabel,
'SET A, then click the globe',
);
const failed = directionsStats({
...idle,
a: A,
b: B,
status: 'error',
error: 'No route found between A and B',
});
assert.equal(failed.error, 'No route found between A and B');
assert.equal(failed.count, 0);
assert.equal(directionsLayer.getStats().count, 0);
});
test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
assert.deepEqual(last.details, ['Step 3 of 3']);
const position = Cesium.Cartesian3.fromDegrees(24.9349, 60.1683, 2);
const card = createDirectionsStepOverlayEntry(1, position, copy);
assert.equal(card.accent, DIRECTIONS_ROUTE_COLOR);
assert.equal(card.id, 'directions-step-1');
assert.equal(createDirectionsStepOverlayEntry(null, position, copy), null);
});
test('the proxy request carries both endpoints, the profile, and asks for steps', () => {
const url = directionsRequestUrl('foot', A, B);
assert.match(
url,
/^\/api\/route\?profile=foot&coords=24\.938400%2C60\.169900%3B24\.655900%2C60\.205500&steps=1$/,
);
});
test('route payloads are validated; a failed route never becomes a straight line', () => {
const ok = normalizeRoutePayload(
{
ok: true,
distanceM: 21479,
durationS: 1478,
geometry: route.geometry,
steps: route.steps,
},
'car',
);
assert.equal(ok.distanceM, 21479);
assert.equal(ok.steps.length, 3);
assert.equal(ok.mode, 'car');
assert.equal(
normalizeRoutePayload({ ok: false, error: 'no route found' }, 'car'),
null,
);
assert.equal(
normalizeRoutePayload({ ok: true, geometry: [[1, 1]] }, 'car'),
null,
);
assert.equal(
normalizeRoutePayload(
{
ok: true,
geometry: [
[999, 1],
[1, 1],
],
},
'car',
),
null,
);
const noSteps = normalizeRoutePayload(
{ ok: true, geometry: route.geometry },
'bike',
);
assert.deepEqual(noSteps.steps, []);
assert.equal(normalizeRoutePayload(null, 'car'), null);
});
test('maneuver dots skip departure and arrival and ride the shared ground floor', () => {
assert.deepEqual(stepMarkerIndices(route.steps), [1]);
assert.deepEqual(stepMarkerIndices([]), []);
assert.deepEqual(stepMarkerIndices(null), []);
assert.deepEqual(
stepMarkerIndices([{}, {}, {}, {}, {}]),
[1, 2, 3],
'every decision between the endpoints gets a dot',
);
// Denver: ground is ~1.6 km ellipsoidal. A dot fixed near zero would sit a
// kilometre and a half under the junction it marks.
const lift = GROUND_FLOOR_LIFT_M;
assert.equal(stepMarkerHeightM(1609.3, lift), 1609.3 + lift);
assert.equal(stepMarkerHeightM(0, lift), lift);
assert.equal(
stepMarkerHeightM(null, lift),
null,
'a cold cell has no honest height',
);
assert.equal(stepMarkerHeightM(undefined, lift), null);
assert.equal(stepMarkerHeightM(Number.NaN, lift), null);
});
test('the turn-by-turn list is ordered, labelled, and highlights the flown step', () => {
assert.equal(directionsStepList({ route: null }), null);
assert.equal(directionsStepList({ route: { steps: [] } }), null);
const list = directionsStepList({
route,
selectedStep: null,
flightStep: null,
});
assert.deepEqual(
list.items.map((item) => item.ordinal),
[1, 2, 3],
'items are in route order',
);
assert.deepEqual(
list.items.map((item) => item.text),
route.steps.map((step) => step.instruction),
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
[{ step: 0 }, { step: 1 }, { step: 2 }],
);
assert.equal(
list.items.some((item) => item.active),
false,
);
const selected = directionsStepList({
route,
selectedStep: 2,
flightStep: null,
});
assert.deepEqual(
selected.items.map((item) => item.active),
[false, false, true],
);
assert.deepEqual(
selected.items.map((item) => item.current),
[false, false, false],
);
// A running FLY owns the highlight; the clicked step yields to it.
const flying = directionsStepList({ route, selectedStep: 2, flightStep: 1 });
assert.deepEqual(
flying.items.map((item) => item.active),
[false, true, false],
);
assert.deepEqual(
flying.items.map((item) => item.current),
[false, true, false],
);
const rowList = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
}).list;
assert.equal(rowList.items.length, 3);
assert.equal(rowList.ariaLabel, 'Turn-by-turn directions');
assert.equal(directionsRowControls(idle).list, null, 'no route, no list');
});
test('the flown step follows distance travelled, never running off either end', () => {
const steps = [{ distanceM: 100 }, { distanceM: 250 }, { distanceM: 0 }];
assert.equal(stepIndexAtDistance(steps, 0), 0);
assert.equal(stepIndexAtDistance(steps, 99), 0);
assert.equal(stepIndexAtDistance(steps, 100), 1);
assert.equal(stepIndexAtDistance(steps, 349), 1);
assert.equal(stepIndexAtDistance(steps, 350), 2, 'the arrival step is last');
assert.equal(stepIndexAtDistance(steps, 99999), 2);
assert.equal(stepIndexAtDistance(steps, -5), 0);
assert.equal(stepIndexAtDistance(steps, Number.NaN), 0);
assert.equal(stepIndexAtDistance([], 10), null);
assert.equal(stepIndexAtDistance(null, 10), null);
});
test('the FLY chip reports a flight in progress', () => {
const flying = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
flightStep: 1,
}).chips.find((chip) => chip.id === 'fly');
assert.equal(flying.label, 'FLYING');
assert.equal(flying.busy, true);
assert.equal(flying.active, true);
});
test('a blocked pointer is reported instead of silently doing nothing', () => {
const blocked = directionsStats({ ...idle, pointerBlocked: true });
assert.match(blocked.error, /Another map tool is using clicks/);
assert.equal(blocked.count, 0);
});
test('a step selection can be requested and cleared through params', () => {
assert.deepEqual(normalizeDirectionsParams({ step: 3 }), { step: 3 });
assert.deepEqual(normalizeDirectionsParams({ step: 0 }), { step: 0 });
assert.deepEqual(normalizeDirectionsParams({ step: null }), { step: null });
assert.deepEqual(normalizeDirectionsParams({ step: -1 }), { step: null });
assert.deepEqual(normalizeDirectionsParams({ step: 'two' }), { step: null });
});
/**
* Drive the live layer module with the overlay host stubbed out. No viewer is
* needed for the arming paths: they are pure state plus the pointer claim.
*/
function ownershipFixture(t) {
directionsLayer = createApplicationDirectionsLayer();
directionsLayer._setOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
resetPointerOwnership();
t.after(() => {
directionsLayer.setParams({ clear: true });
directionsLayer._setOverlayHostForTest(null);
resetPointerOwnership();
});
}
test('placing an endpoint takes the pointer, and every exit path gives it back', (t) => {
ownershipFixture(t);
assert.equal(isPointerFree(), true);
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'arming claims the pointer',
);
assert.equal(
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-a').active,
true,
);
// Re-arming for the other endpoint keeps the same claim; it never stacks.
directionsLayer.setParams({ arm: 'b' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// Cancel (clicking the armed chip again).
directionsLayer.setParams({ arm: null });
assert.equal(isPointerFree(), true, 'cancelling releases');
assert.equal(
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-b').active,
false,
);
// CLEAR while armed.
directionsLayer.setParams({ arm: 'a' });
directionsLayer.setParams({ clear: true });
assert.equal(isPointerFree(), true, 'CLEAR releases');
// disable() while armed.
directionsLayer.setParams({ arm: 'a' });
directionsLayer.disable(null);
assert.equal(isPointerFree(), true, 'disabling the layer releases');
// destroy() while armed, on a layer that was never enabled.
directionsLayer.setParams({ arm: 'b' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
directionsLayer.destroy({ scene: { primitives: { remove() {} } } });
assert.equal(isPointerFree(), true, 'destroy releases');
});
test('arming is refused, and said so, while another tool holds the pointer', (t) => {
ownershipFixture(t);
const releaseDraw = claimAs('draw');
assert.equal(
directionsLayer.setParams({ arm: 'a' }),
true,
'the write is accepted',
);
assert.equal(
pointerOwner(),
'draw',
"the other tool's claim is never stolen",
);
const chips = directionsLayer.getRowControls().chips;
assert.equal(
chips.find((chip) => chip.id === 'set-a').active,
false,
'nothing is armed',
);
assert.match(
directionsLayer.getStats().error,
/Another map tool is using clicks/,
'the row says why the click did nothing',
);
// Once the other tool lets go, arming works and the complaint clears.
assert.equal(releaseDraw(), true);
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(directionsLayer.getStats().error, null);
});
test('a release names this layer, so it cannot free a successor tool claim', (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// A superseding teardown: the layer is disabled, then another tool claims.
directionsLayer.disable(null);
const releaseDraw = claimAs('draw');
// A late second teardown of ours must not free the draw tool's claim.
directionsLayer.disable(null);
directionsLayer.setParams({ clear: true });
assert.equal(pointerOwner(), 'draw');
releaseDraw();
});
test('the pointer claim outlives the click that consumed it', async (t) => {
ownershipFixture(t);
// Every layer binds its own handler to the same canvas and they all run
// inside ONE browser event. Releasing the claim the moment the endpoint is
// placed hands the rest of that same click to every ambient handler
// registered after this one — which is how placing A on top of a bikeshare
// station also deselected the station.
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }),
true,
);
// Still inside the dispatch: an ambient handler that runs after this one
// asks the same question the real ones ask, and must be told to stand down.
assert.equal(
isPointerFree(),
false,
'a later handler in the same click yields',
);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// A microtask checkpoint runs BETWEEN two DOM listeners, so the release must
// not be on one.
await Promise.resolve();
await Promise.resolve();
assert.equal(
isPointerFree(),
false,
'a microtask is not the end of the click',
);
// The click is over; the pointer goes back.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
isPointerFree(),
true,
'the claim is returned once the click ends',
);
// A bad placement request changes nothing.
directionsLayer.setParams({ arm: 'b' });
assert.equal(directionsLayer.placeEndpoint('c', { lat: 1, lon: 1 }), false);
assert.equal(
directionsLayer.placeEndpoint('b', { lat: Number.NaN, lon: 1 }),
false,
);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER, 'still armed');
});
test('a teardown during the pending release still frees the pointer at once', async (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
assert.equal(isPointerFree(), false);
// Disabling mid-click must not wait for a timer that may never be reached.
directionsLayer.disable(null);
assert.equal(isPointerFree(), true);
// And the pending timer cannot then release someone else's later claim.
const releaseDraw = claimAs('draw');
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(pointerOwner(), 'draw');
releaseDraw();
});
test('a cold ground cell is re-read for as long as the terrain proxy can take', () => {
// The proxy behind the shared floor can take tens of seconds on a cold
// cache. Twelve quick retries covered four seconds of that.
assert.ok(
STEP_ANCHOR_DEADLINE_MS >= 60_000,
`the budget must outlast a slow terrain round trip, is ${STEP_ANCHOR_DEADLINE_MS} ms`,
);
assert.equal(stepAnchorDelayMs(0, 0), STEP_ANCHOR_RETRY_MS);
assert.equal(
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS - 1, 1000),
STEP_ANCHOR_RETRY_MS,
);
assert.equal(
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS, 2000),
STEP_ANCHOR_SLOW_RETRY_MS,
'the re-reads back off once the quick ones have not settled it',
);
// The last wait never overshoots the budget, and past it there is no wait.
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS - 100), 100);
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS), null);
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS + 1), null);
assert.equal(stepAnchorDelayMs(0, Number.NaN), null);
});
test('a route cut off at the step cap says so instead of ending at a random turn', () => {
const cut = { ...route, stepsTruncated: true };
const list = directionsStepList({ route: cut });
assert.equal(list.items.length, route.steps.length + 1);
const last = list.items.at(-1);
assert.match(last.text, /Only the first 3 turns of this route are shown/);
assert.equal(last.disabled, true, 'the note is not an action');
assert.equal(last.params, undefined, 'and carries no command');
assert.match(
directionsStats({ ...idle, a: A, b: B, status: 'ready', route: cut })
.coverage,
/first 3 turns$/,
);
// A complete route says nothing extra.
assert.equal(directionsStepList({ route }).items.length, route.steps.length);
assert.equal(
normalizeRoutePayload(
{ ok: true, geometry: route.geometry, steps: route.steps },
'car',
).stepsTruncated,
false,
);
assert.equal(
normalizeRoutePayload(
{
ok: true,
geometry: route.geometry,
steps: route.steps,
stepsTruncated: true,
},
'car',
).stepsTruncated,
true,
);
});
test('re-arming reuses the lease this layer already holds', (t) => {
ownershipFixture(t);
// A claim never stacks, not even under the same name — two live instances of
// one tool are two owners. So arming B after arming A must not ask for a
// second claim it cannot get.
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(directionsLayer.setParams({ arm: 'b' }), true);
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'the layer still holds the pointer after re-arming',
);
const chips = directionsLayer.getRowControls().chips;
assert.equal(chips.find((chip) => chip.id === 'set-b').active, true);
assert.equal(
directionsLayer.getStats().error,
null,
'and nothing is refused',
);
});
test('a stale lease cannot free the claim a later arming holds', async (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
// The release for that click is pending. Arming again before it fires must
// keep the pointer, not lose it to the timer a moment later.
directionsLayer.setParams({ arm: 'b' });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'the pending release must not disarm the new arming',
);
});
test('a refused arming names the tool holding the pointer and how to leave it', () => {
// Owner field test: he pressed SET A while Draw was open, nothing happened,
// and he concluded Directions was broken. Silence was the bug.
assert.equal(
pointerBlockedMessage('draw', 'a'),
'Draw is active — press Escape twice to leave Draw, then set A',
);
assert.equal(
pointerBlockedMessage('draw', 'b'),
'Draw is active — press Escape twice to leave Draw, then set B',
);
// A tool nobody has written an exit for still gets a usable sentence.
assert.equal(
pointerBlockedMessage('measure', 'a'),
'measure is active — turn measure off, then set A',
);
// Nothing to say when nothing is in the way, or when we are the holder.
assert.equal(pointerBlockedMessage(null, 'a'), null);
assert.equal(pointerBlockedMessage('', 'a'), null);
assert.equal(pointerBlockedMessage(' ', 'a'), null);
assert.equal(pointerBlockedMessage(DIRECTIONS_POINTER_OWNER, 'a'), null);
// Every exit the table knows names its tool and says how to leave it.
for (const [id, exit] of Object.entries(POINTER_TOOL_EXITS)) {
assert.match(
pointerBlockedMessage(id, 'a'),
new RegExp(`^${exit.name} is active — `),
);
assert.match(pointerBlockedMessage(id, 'a'), /, then set A$/);
}
});
test('the refusal reaches the app toast, and a successful arming says nothing', (t) => {
ownershipFixture(t);
const toasts = [];
directionsLayer.attachShellServices({
showToast: (text) => toasts.push(text),
});
t.after(() => directionsLayer.attachShellServices(null));
const releaseDraw = claimAs('draw');
directionsLayer.setParams({ arm: 'a' });
assert.deepEqual(toasts, [
'Draw is active — press Escape twice to leave Draw, then set A',
]);
// The row status the operator can also read is still set.
assert.match(
directionsLayer.getStats().error,
/Another map tool is using clicks/,
);
// Once Draw lets go, arming works and says nothing at all.
releaseDraw();
directionsLayer.setParams({ arm: 'a' });
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
+1
View File
@@ -279,6 +279,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only' }),
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
+2 -2
View File
@@ -158,8 +158,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 17);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
assert.equal(REGISTERED_LAYER_IDS.length, 18);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18);
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
+11
View File
@@ -14,6 +14,17 @@ export class DataLayerManager extends LayerLifecycle {
_refreshTogglePanel() {
this._presentation.refresh();
}
/**
* Repaint the toggle panel now. For layers whose data arrives outside their
* manager tick (camera-driven loads such as Transit's proximity polls), so a
* row shows its count when the data lands instead of at the next interval.
* One DOM pass; skipped while the document is hidden.
*/
refreshLayerStats() {
this._refreshTogglePanel();
}
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
+222
View File
@@ -0,0 +1,222 @@
/**
* @module routeSteps
* @description Turn-by-turn steps from an OSRM route, in plain English.
*
* OSRM (the FOSSGIS servers behind `/api/route`) returns each step as a
* maneuver `type` + `modifier` (+ roundabout `exit`) and the road name; it
* does not phrase instructions. This module does, with the same vocabulary
* OSRM's own text-instructions package uses so a reader of either feels at
* home. Pure: used by the Vite proxy and node:test.
*
* OSRM maneuver types handled: depart, arrive, turn, new name, continue,
* end of road, fork, merge, on ramp, off ramp, roundabout, rotary,
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
const DIRECTION_WORDS = Object.freeze({
left: 'left',
right: 'right',
'slight left': 'slightly left',
'slight right': 'slightly right',
'sharp left': 'sharply left',
'sharp right': 'sharply right',
straight: 'straight',
uturn: 'around',
});
function cleanText(value) {
const text = typeof value === 'string' ? value.trim() : '';
return text;
}
/**
* Road label for a step: "Name (Ref)", "Name", "Ref", or "".
* @param {{name?: string, ref?: string}} step
* @returns {string}
*/
export function roadLabel(step) {
const name = cleanText(step?.name);
const ref = cleanText(step?.ref);
if (name && ref && !name.includes(ref)) return `${name} (${ref})`;
return name || ref;
}
function ontoRoad(step) {
const road = roadLabel(step);
return road ? ` onto ${road}` : '';
}
function direction(modifier) {
const key = cleanText(modifier).toLowerCase();
return DIRECTION_WORDS[key] || key;
}
function ordinal(n) {
if (!Number.isInteger(n) || n < 1) return null;
const suffix =
n % 100 >= 11 && n % 100 <= 13
? 'th'
: { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return `${n}${suffix}`;
}
/**
* One English sentence for an OSRM step.
* @param {{type?: string, modifier?: string, exit?: number, name?: string, ref?: string}} step
* @returns {string}
*/
export function instructionFor(step) {
const type = cleanText(step?.type).toLowerCase();
const modifier = cleanText(step?.modifier).toLowerCase();
const dir = direction(modifier);
const onto = ontoRoad(step);
const road = roadLabel(step);
switch (type) {
case 'depart':
return road ? `Head out on ${road}` : 'Head out';
case 'arrive':
if (modifier === 'left' || modifier === 'right')
return `Arrive at your destination, on the ${modifier}`;
return 'Arrive at your destination';
case 'turn':
if (modifier === 'uturn') return `Make a U-turn${onto}`;
if (modifier === 'straight') return `Continue straight${onto}`;
return dir ? `Turn ${dir}${onto}` : `Turn${onto}`;
case 'new name':
return `Continue${onto}`;
case 'continue':
if (modifier === 'uturn') return `Make a U-turn${onto}`;
return dir && dir !== 'straight'
? `Continue ${dir}${onto}`
: `Continue straight${onto}`;
case 'end of road':
return dir
? `At the end of the road, turn ${dir}${onto}`
: `At the end of the road, continue${onto}`;
case 'fork':
return dir
? `Keep ${dir} at the fork${onto}`
: `Continue at the fork${onto}`;
case 'merge':
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
case 'on ramp':
return dir
? `Take the ramp on the ${dir}${onto}`
: `Take the ramp${onto}`;
case 'off ramp':
return dir
? `Take the exit on the ${dir}${onto}`
: `Take the exit${onto}`;
case 'roundabout':
case 'rotary': {
const exit = ordinal(step?.exit);
return exit
? `At the roundabout, take the ${exit} exit${onto}`
: `Enter the roundabout${onto}`;
}
case 'roundabout turn':
return dir
? `At the roundabout, turn ${dir}${onto}`
: `At the roundabout, continue${onto}`;
case 'exit roundabout':
case 'exit rotary':
return `Exit the roundabout${onto}`;
case 'use lane':
return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`;
case 'notification':
default:
return `Continue${onto}`;
}
}
/**
* Flatten an OSRM route's legs into the compact step list the proxy serves.
* "exit roundabout" / "exit rotary" steps are folded into the roundabout
* step before them (their distance is the road after the roundabout), so a
* reader gets one instruction per decision.
* A route with more maneuvers than `ROUTE_STEPS_MAX` is cut off there and
* reported as cut off, because the last step of a route is its arrival: a
* silently truncated list reads as a complete set of directions that simply
* stops in the middle of a motorway.
* @param {{legs?: Array<{steps?: object[]}>}} route OSRM route object.
* @returns {{steps: Array<{index:number, type:string, modifier:string|null, exit:number|null,
* name:string, ref:string|null, distanceM:number, durationS:number, lon:number, lat:number,
* instruction:string}>, truncated: boolean}}
*/
export function normalizeOsrmSteps(route) {
const out = [];
for (const leg of route?.legs || []) {
for (const raw of leg?.steps || []) {
const location = raw?.maneuver?.location;
const lon = Number(location?.[0]);
const lat = Number(location?.[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const type = cleanText(raw.maneuver?.type).toLowerCase() || 'continue';
const step = {
index: out.length,
type,
modifier: cleanText(raw.maneuver?.modifier).toLowerCase() || null,
exit: Number.isInteger(raw.maneuver?.exit) ? raw.maneuver.exit : null,
name: cleanText(raw.name),
ref: cleanText(raw.ref) || null,
distanceM: Math.max(0, Math.round(Number(raw.distance) || 0)),
durationS: Math.max(0, Math.round(Number(raw.duration) || 0)),
lon: Number(lon.toFixed(6)),
lat: Number(lat.toFixed(6)),
instruction: '',
};
const previous = out[out.length - 1];
if (
(type === 'exit roundabout' || type === 'exit rotary') &&
previous &&
(previous.type === 'roundabout' ||
previous.type === 'rotary' ||
previous.type === 'roundabout turn')
) {
previous.distanceM += step.distanceM;
previous.durationS += step.durationS;
if (!roadLabel(previous) && roadLabel(step)) {
previous.name = step.name;
previous.ref = step.ref;
previous.instruction = instructionFor(previous);
}
continue;
}
step.instruction = instructionFor(step);
out.push(step);
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
}
}
return { steps: out, truncated: false };
}
/**
* "850 m", "1.2 km", "21 km".
* @param {number} meters
* @returns {string}
*/
export function formatRouteDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return `${Math.round(meters)} m`;
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
}
/**
* "40 s", "12 min", "1 h 5 min".
* @param {number} seconds
* @returns {string}
*/
export function formatRouteDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${Math.round(seconds)} s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours} h ${rest} min` : `${hours} h`;
}
+226
View File
@@ -0,0 +1,226 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ROUTE_STEPS_MAX,
formatRouteDistance,
formatRouteDuration,
instructionFor,
normalizeOsrmSteps,
roadLabel,
} from './routeSteps.js';
test('instructions read like a navigator, one sentence per OSRM maneuver', () => {
assert.equal(
instructionFor({ type: 'depart', name: 'Kaivokatu' }),
'Head out on Kaivokatu',
);
assert.equal(instructionFor({ type: 'depart' }), 'Head out');
assert.equal(
instructionFor({
type: 'new name',
modifier: 'straight',
name: 'Simonkatu',
}),
'Continue onto Simonkatu',
);
assert.equal(
instructionFor({
type: 'end of road',
modifier: 'left',
name: 'Annankatu',
}),
'At the end of the road, turn left onto Annankatu',
);
assert.equal(
instructionFor({ type: 'turn', modifier: 'right', name: 'Kansakoulukatu' }),
'Turn right onto Kansakoulukatu',
);
assert.equal(
instructionFor({
type: 'turn',
modifier: 'slight left',
name: 'Ring I',
ref: 'Kt 50',
}),
'Turn slightly left onto Ring I (Kt 50)',
);
assert.equal(
instructionFor({ type: 'turn', modifier: 'uturn' }),
'Make a U-turn',
);
assert.equal(
instructionFor({
type: 'roundabout',
modifier: 'right',
exit: 2,
name: 'Malminrinne',
}),
'At the roundabout, take the 2nd exit onto Malminrinne',
);
assert.equal(
instructionFor({ type: 'rotary', exit: 1 }),
'At the roundabout, take the 1st exit',
);
assert.equal(
instructionFor({ type: 'roundabout', exit: 11 }),
'At the roundabout, take the 11th exit',
);
assert.equal(
instructionFor({ type: 'fork', modifier: 'slight right', ref: 'E12' }),
'Keep slightly right at the fork onto E12',
);
assert.equal(
instructionFor({ type: 'off ramp', modifier: 'right', name: 'Turunväylä' }),
'Take the exit on the right onto Turunväylä',
);
assert.equal(
instructionFor({ type: 'on ramp', modifier: 'left' }),
'Take the ramp on the left',
);
assert.equal(
instructionFor({
type: 'merge',
modifier: 'slight left',
name: 'Länsiväylä',
}),
'Merge slightly left onto Länsiväylä',
);
assert.equal(
instructionFor({ type: 'arrive', modifier: 'right' }),
'Arrive at your destination, on the right',
);
assert.equal(
instructionFor({ type: 'arrive' }),
'Arrive at your destination',
);
assert.equal(
instructionFor({ type: 'made-up', name: 'X' }),
'Continue onto X',
);
assert.equal(instructionFor(null), 'Continue');
});
test('road label prefers "Name (Ref)" and never repeats a ref already in the name', () => {
assert.equal(roadLabel({ name: 'Ring I', ref: 'Kt 50' }), 'Ring I (Kt 50)');
assert.equal(
roadLabel({ name: 'E12 Turunväylä', ref: 'E12' }),
'E12 Turunväylä',
);
assert.equal(roadLabel({ ref: 'E12' }), 'E12');
assert.equal(roadLabel({}), '');
});
test('normalizeOsrmSteps flattens legs, folds roundabout exits, and drops steps without a location', () => {
const route = {
legs: [
{
steps: [
{
maneuver: { type: 'depart', location: [24.9384, 60.1699] },
name: 'Kaivokatu',
distance: 8.3,
duration: 1.3,
},
{
maneuver: {
type: 'roundabout',
modifier: 'right',
exit: 2,
location: [24.9313, 60.1675],
},
name: '',
distance: 34.7,
duration: 7.3,
},
{
maneuver: {
type: 'exit roundabout',
modifier: 'slight right',
exit: 2,
location: [24.931, 60.1674],
},
name: 'Malminrinne',
distance: 100.8,
duration: 17.1,
},
{
maneuver: { type: 'turn', modifier: 'left' },
name: 'Nowhere',
distance: 5,
duration: 1,
},
],
},
{
steps: [
{
maneuver: {
type: 'arrive',
modifier: 'left',
location: [24.6559, 60.2055],
},
name: 'Kirkkojärventie',
distance: 0,
duration: 0,
},
],
},
],
};
const { steps, truncated } = normalizeOsrmSteps(route);
assert.equal(truncated, false, 'a short route is complete');
assert.deepEqual(
steps.map((s) => s.type),
['depart', 'roundabout', 'arrive'],
);
assert.equal(steps[1].distanceM, 136);
assert.equal(steps[1].durationS, 24);
assert.equal(
steps[1].instruction,
'At the roundabout, take the 2nd exit onto Malminrinne',
);
assert.deepEqual(
steps.map((s) => s.index),
[0, 1, 2],
);
assert.equal(steps[2].instruction, 'Arrive at your destination, on the left');
assert.equal(steps[0].lon, 24.9384);
assert.equal(steps[0].lat, 60.1699);
assert.deepEqual(normalizeOsrmSteps(null), { steps: [], truncated: false });
assert.deepEqual(normalizeOsrmSteps({ legs: [{ steps: null }] }), {
steps: [],
truncated: false,
});
});
test('the step list is capped, and the cap is reported rather than hidden', () => {
const many = Array.from({ length: ROUTE_STEPS_MAX + 50 }, (_, i) => ({
maneuver: { type: 'turn', modifier: 'left', location: [i * 0.001, 0.5] },
name: `Street ${i}`,
distance: 10,
duration: 2,
}));
const cut = normalizeOsrmSteps({ legs: [{ steps: many }] });
assert.equal(cut.steps.length, ROUTE_STEPS_MAX);
// The arrival step is among the ones dropped; a caller that is not told so
// would present a route that simply stops in the middle of a road.
assert.equal(cut.truncated, true);
const exact = normalizeOsrmSteps({
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
});
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
assert.equal(exact.truncated, false, 'a route that fits is not cut');
});
test('distance and duration formatting', () => {
assert.equal(formatRouteDistance(8), '8 m');
assert.equal(formatRouteDistance(846), '850 m');
assert.equal(formatRouteDistance(1234), '1.2 km');
assert.equal(formatRouteDistance(21479), '21 km');
assert.equal(formatRouteDistance(-1), '');
assert.equal(formatRouteDuration(40), '40 s');
assert.equal(formatRouteDuration(1478), '25 min');
assert.equal(formatRouteDuration(3900), '1 h 5 min');
assert.equal(formatRouteDuration(7200), '2 h');
assert.equal(formatRouteDuration(NaN), '');
});
+1
View File
@@ -3,6 +3,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv',
'firms',
'bikeshare',
'directions',
'ais',
'military',
'flights',
File diff suppressed because it is too large. Load diff
+78
View File
@@ -18,8 +18,11 @@ import {
createRouteFlight,
flyRoute,
getActiveCameraMotion,
activeCameraMotionId,
initCameraVerbs,
interruptCameraMotion,
interruptCameraMotionIfActive,
moveCamera,
prefersReducedMotion,
routeAltitudeOffsetM,
routeColdSeedFloorM,
@@ -1000,3 +1003,78 @@ test('the 0.5 s duration floor is the one place the speed word is not the mean',
const normal = flightFrom(TWO_TURN_ROUTE);
assert.ok(Math.abs((normal.totalM / normal.durationS) - CRUISE_M_S.normal) < 1e-9);
});
test('a flight can be stopped by its owner, and only while it is still theirs', () => {
// CLEAR in the Directions row must stop the flight IT started. By then the
// camera may belong to someone else — the user grabbed it, voice flew
// somewhere, a tracked aircraft took over — and stopping that would be a
// feature reaching across the app to cancel a stranger's motion.
const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null);
const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 }));
assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id');
const first = flyRoute([{ type: 'route', label: 'mine', path }]);
assert.equal(first.ok, true);
assert.ok(first.motionId > 0, 'a started flight reports its id');
assert.equal(activeCameraMotionId(), first.motionId);
assert.equal(getActiveCameraMotion().motionId, first.motionId);
// Someone else replaces it. The first owner's cancel must now be inert.
const second = flyRoute([{ type: 'route', label: 'theirs', path }]);
assert.ok(second.motionId > first.motionId, 'ids are monotonic');
assert.equal(
interruptCameraMotionIfActive(first.motionId, 'stale-owner').wasActive,
false,
);
assert.equal(
activeCameraMotionId(),
second.motionId,
'the newer flight is still running',
);
// The current owner stops its own flight.
assert.equal(
interruptCameraMotionIfActive(second.motionId, 'owner-cancel').wasActive,
true,
);
assert.equal(activeCameraMotionId(), 0);
assert.equal(getActiveCameraMotion(), null);
// A second cancel, and a cancel with no id at all, stop nothing.
assert.equal(interruptCameraMotionIfActive(second.motionId).wasActive, false);
assert.equal(interruptCameraMotionIfActive(0).wasActive, false);
viewer.restore();
});
test('a new viewer lifetime drops the previous view-target getter', () => {
// The getter closes over the viewer it was built for. Re-initialising for a
// new viewer without one must not leave the old callback answering.
const first = createTickableViewer();
const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0);
let asked = 0;
initCameraVerbs(first.viewer, () => { asked += 1; return target; });
const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(orbit.ok, true);
assert.equal(orbit.armed, undefined, 'the installed getter supplied a target');
assert.ok(asked > 0);
interruptCameraMotion('test-cleanup');
// Same viewer, no getter: the installed one stays.
initCameraVerbs(first.viewer);
const again = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(again.armed, undefined, 'the getter survives a call without one');
interruptCameraMotion('test-cleanup');
first.restore();
// New viewer, no getter: the old one is gone, so the orbit arms instead of
// capturing a target from a viewer that no longer exists.
const second = createTickableViewer();
initCameraVerbs(second.viewer);
const askedBefore = asked;
const armed = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(armed.armed, 'waiting-for-arrival');
assert.equal(asked, askedBefore, 'the old getter was never called again');
interruptCameraMotion('test-cleanup');
second.restore();
});
+256
View File
@@ -4,6 +4,14 @@ import {
googlePlacesContextProxy,
installRouteMiddleware,
} from '../../server/providers/places.js';
import {
ROUTE_UPSTREAM_MIN_INTERVAL_MS,
ROUTE_UPSTREAM_QUEUE_MAX,
_resetRouteUpstreamForTest,
_routeInflightCountForTest,
_setRouteUpstreamIntervalForTest,
} from '../../server/providers/places/routes.js';
import { ROUTE_STEPS_MAX } from '../../src/data/routeSteps.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -226,3 +234,251 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be
);
assert.equal((await request(route, query)).body.error, 'no route found');
});
/** One OSRM route with `count` maneuvers, for the step tests. */
function osrmRouteWithSteps(count) {
return {
code: 'Ok',
routes: [
{
distance: 123.6,
duration: 80.2,
geometry: {
coordinates: [
[-97, 30],
[-97.01, 30.01],
],
},
legs: [
{
steps: Array.from({ length: count }, (_, i) => ({
name: `Road ${i}`,
distance: 10,
duration: 5,
maneuver: {
type: i === 0 ? 'depart' : 'turn',
modifier: 'left',
location: [-97 + i / 1000, 30],
},
})),
},
],
},
],
};
}
test('steps are opt-in: the upstream is only asked for them when a caller is', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(String(url));
return Response.json(osrmRouteWithSteps(3));
});
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01';
// The voice route annotation and fly_route do not read maneuvers. What they
// get back must be exactly what this endpoint returned before steps existed.
const plain = await request(route, query);
assert.deepEqual(plain.body, {
ok: true,
profile: 'car',
distanceM: 124,
durationS: 80,
geometry: [
[-97, 30],
[-97.01, 30.01],
],
});
assert.equal('steps' in plain.body, false, 'no steps key at all');
assert.match(asked[0], /steps=false/, 'the upstream payload stays small');
// Directions asks, and gets them.
_resetRouteUpstreamForTest();
const withSteps = await request(route, `${query}&steps=1`);
assert.equal(asked.length, 2);
assert.match(asked[1], /steps=true/);
assert.equal(withSteps.body.steps.length, 3);
assert.equal(withSteps.body.steps[0].instruction, 'Head out on Road 0');
assert.equal(withSteps.body.steps[1].instruction, 'Turn left onto Road 1');
// A stepful cache entry still serves a stepless caller its own shape.
const reuse = await request(route, query);
assert.equal(asked.length, 2, 'served from cache');
assert.deepEqual(
reuse.body,
plain.body,
'byte-identical to the no-steps shape',
);
});
test('identical concurrent route requests make one upstream call', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
let calls = 0;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
await gate;
return Response.json(osrmRouteWithSteps(4));
});
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01&steps=1';
// Three rapid reroutes of the same A→B, before any of them answers.
const all = [
request(route, query),
request(route, query),
request(route, query),
];
// ...plus a caller that wants no steps; it rides the same call.
all.push(request(route, '?profile=car&coords=-97,30;-97.01,30.01'));
await Promise.resolve();
assert.equal(
_routeInflightCountForTest(),
1,
'one upstream call is in flight',
);
release();
const results = await Promise.all(all);
assert.equal(calls, 1, 'the FOSSGIS servers saw one request, not four');
assert.equal(results[0].body.steps.length, 4);
assert.deepEqual(results[0].body, results[1].body);
assert.deepEqual(results[0].body, results[2].body);
assert.equal(
'steps' in results[3].body,
false,
'the stepless caller still gets no steps',
);
assert.equal(
_routeInflightCountForTest(),
0,
'the in-flight entry is released',
);
});
test('a pathological maneuver count is bounded before it reaches the browser', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () =>
Response.json(osrmRouteWithSteps(5000)),
);
const result = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01&steps=1',
);
assert.equal(result.body.steps.length, ROUTE_STEPS_MAX);
assert.ok(ROUTE_STEPS_MAX <= 200, 'the cap stays a cap');
});
test('outbound route requests are spaced to the rate the routing service asks for', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const sentAt = [];
t.mock.method(globalThis, 'fetch', async (url) => {
sentAt.push({ at: Date.now(), url: String(url) });
return Response.json(osrmRouteWithSteps(2));
});
// FOSSGIS publish "one request per second max". The per-client limiter alone
// cannot honour that: a DRIVE and a WALK of the same trip are two different
// cache keys and used to leave 48 ms apart.
assert.ok(ROUTE_UPSTREAM_MIN_INTERVAL_MS >= 1000);
const first = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(first.body.ok, true);
assert.equal(sentAt.length, 1);
const second = request(
'/api/route',
'?profile=foot&coords=-97,30;-97.01,30.01',
);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(sentAt.length, 1, 'the second profile waits for its slot');
assert.equal((await second).body.ok, true);
assert.ok(
sentAt[1].at - sentAt[0].at >= ROUTE_UPSTREAM_MIN_INTERVAL_MS - 20,
`two outbound calls ${sentAt[1].at - sentAt[0].at} ms apart`,
);
// A cached route costs no outbound slot at all.
const cached = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(cached.body.ok, true);
assert.equal(sentAt.length, 2, 'the cache answers without an upstream call');
});
test('past the outbound queue depth the answer is an honest 429, not a growing queue', async (t) => {
_resetRouteUpstreamForTest();
// The real gate is one per second (asserted above); this test is about what
// happens past the queue depth, so it runs the gate fast.
_setRouteUpstreamIntervalForTest(5);
t.after(() => _resetRouteUpstreamForTest());
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () =>
Response.json(osrmRouteWithSteps(2)),
);
const pending = [];
for (let i = 0; i < ROUTE_UPSTREAM_QUEUE_MAX + 3; i += 1) {
pending.push(
request(
'/api/route',
`?profile=car&coords=-97,${30 + i / 100};-97.01,30.01`,
),
);
}
const results = await Promise.all(pending);
const refused = results.filter((result) => result.statusCode === 429);
assert.ok(refused.length >= 1, 'the excess is refused rather than queued');
assert.match(refused[0].body.error, /routing busy/);
assert.ok(
Number(refused[0].headers['retry-after']) >= 1,
`Retry-After is ${refused[0].headers['retry-after']}`,
);
// Everything that was admitted still got a real answer.
assert.ok(results.some((result) => result.body.ok === true));
});
test('an upstream rate limit is reported as one, not as "no route found"', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
t.mock.method(
globalThis,
'fetch',
async () =>
new Response('slow down', {
status: 429,
headers: { 'retry-after': '30', 'content-type': 'text/plain' },
}),
);
const result = await request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
assert.equal(result.statusCode, 429);
assert.match(result.body.error, /routing service is rate limited/);
assert.equal(
result.headers['retry-after'],
'30',
"the service's own wait is passed on",
);
});
test('the upstream host is pinned: a redirect is refused, not followed', async (t) => {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
seen.push(options?.redirect);
return Response.json(osrmRouteWithSteps(2));
});
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
});
+32
View File
@@ -265,6 +265,7 @@ export class StyleManager {
// Bloom/sharpen state
this._globeResetPromise = null;
this._dataManager = null;
this._directionsShellModule = null;
this._windowResizeHandler = null;
this._cctvRequestFocusHandler = null;
@@ -929,6 +930,34 @@ export class StyleManager {
return this._runExplicitNavigation(noun, navigate, releaseOptions);
}
/**
* Hand the Directions layer the camera seams its FLY chip needs: the same
* immediate-navigation facade voice route flights go through, so there is
* one camera owner rather than a second one inside a data layer, the shared
* ground-floor read/warm the route dolly flies over, and the app's own toast
* so the layer can speak where the rest of the UI speaks.
* @returns {void}
*/
_connectDirectionsCamera() {
if (!this._dataManager) {
// Detaching: the layer outlives this shell, so it must not keep calling
// a facade whose viewer is going away.
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
return;
}
const directions = this._dataManager.layers?.get('directions')?.module;
if (typeof directions?.attachShellServices !== 'function') return;
this._directionsShellModule = directions;
directions.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('route', navigate),
floorFn: (lat, lon) => this.services.cachedGroundFloor(lat, lon),
warmFn: (cells) => this.services.warmGroundFloor(cells),
showToast: (message) => this._showToast(message),
});
}
/** Supersede deferred work when an owner-specific route handles release. */
supersedeDeferredNavigation() {
return this._stampNavigation();
@@ -1654,6 +1683,7 @@ export class StyleManager {
this._syncContextModeButtons();
this._cctvControls.connect();
this._radioControls.connect();
this._connectDirectionsCamera();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) =>
this._persistAwarenessSelection(event, false);
@@ -3683,6 +3713,8 @@ export class StyleManager {
this._contextControls.disconnect();
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
if (this._windowResizeHandler) {
window.removeEventListener('resize', this._windowResizeHandler);
+103 -3
View File
@@ -206,7 +206,23 @@ export class LayerPanel {
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
}
this._toggleContainer.appendChild(row);
@@ -232,12 +248,14 @@ export class LayerPanel {
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
*/
_syncRowControls(container, layer) {
_syncRowControls(container, layer, listContainer = null) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
@@ -285,6 +303,84 @@ export class LayerPanel {
}
}
/**
* Render a row's ordered list (turn-by-turn directions).
*
* Each entry is a real `<button>` inside a real `<li>`, so Tab reaches it and
* Enter activates it with no key handling of our own, and the `<ol>` carries
* the ordering a screen reader announces. Items are reconciled in place,
* keyed by id, for the same reason chips are: this runs on every refresh —
* including the one a click on the list triggers — and replacing the node
* would drop keyboard focus mid-interaction.
* @param {HTMLElement|null} container The row's `.data-row-list` node.
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
*/
_syncRowList(container, list) {
if (!container) return;
const items = list?.items || [];
container.hidden = items.length === 0;
if (list?.ariaLabel) container.setAttribute('aria-label', list.ariaLabel);
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
}
let previous = null;
let activeButton = null;
for (const item of items) {
let entry = stale.get(item.id);
stale.delete(item.id);
let button;
if (!entry) {
entry = document.createElement('li');
entry.dataset.listItemId = item.id;
button = document.createElement('button');
button.type = 'button';
button.className = 'data-row-list-item';
button.dataset.listItemId = item.id;
const lead = document.createElement('span');
lead.className = 'data-row-list-lead';
const text = document.createElement('span');
text.className = 'data-row-list-text';
button.append(lead, text);
entry.appendChild(button);
} else {
button = entry.querySelector('.data-row-list-item');
}
// Keep DOM order in step with descriptor order without rebuilding.
const anchor = previous ? previous.nextSibling : container.firstChild;
if (entry !== anchor) container.insertBefore(entry, anchor);
previous = entry;
if (!button) continue;
const lead = button.querySelector('.data-row-list-lead');
const text = button.querySelector('.data-row-list-text');
const leadText = String(item.lead ?? '');
const bodyText = String(item.text ?? '');
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
if (text && text.textContent !== bodyText) text.textContent = bodyText;
button.disabled = Boolean(item.disabled);
button.classList.toggle('note', Boolean(item.disabled));
button.classList.toggle('active', Boolean(item.active));
button.classList.toggle('current', Boolean(item.current));
button.setAttribute('aria-current', item.current ? 'step' : 'false');
button.setAttribute('aria-pressed', item.active ? 'true' : 'false');
button.title = bodyText;
if (item.current) activeButton = button;
}
for (const node of stale.values()) node.remove();
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
if (
activeButton &&
container.dataset.currentId !== activeButton.dataset.listItemId
) {
container.dataset.currentId = activeButton.dataset.listItemId;
activeButton.scrollIntoView?.({ block: 'nearest' });
} else if (!activeButton) {
delete container.dataset.currentId;
}
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
@@ -314,7 +410,11 @@ export class LayerPanel {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
this._syncRowControls(
row.querySelector('.data-toggle-controls'),
layer,
row.querySelector('.data-row-list'),
);
}
}
+88
View File
@@ -800,3 +800,91 @@
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
flex: none;
}
/* Optional per-layer ordered list: the turn-by-turn directions under the
Directions row. Kept short and scrollable so a 40-step drive cannot push the
rest of the rail off screen. */
.data-row-list {
list-style: none;
margin: 4px 0 0;
padding: 0 0 0 26px;
max-height: 150px;
overflow-y: auto;
counter-reset: gev-step;
}
.data-row-list[hidden] {
display: none;
}
.data-row-list-item {
counter-increment: gev-step;
display: flex;
align-items: baseline;
gap: 6px;
width: 100%;
padding: 2px 4px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.4px;
text-align: left;
color: var(--text-dim);
background: none;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
outline: none;
}
.data-row-list-item::before {
content: counter(gev-step);
min-width: 13px;
text-align: right;
color: var(--text-dim);
opacity: 0.65;
flex: none;
}
.data-row-list-item:hover,
.data-row-list-item:focus-visible {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
}
.data-row-list-item.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-dim);
}
/* The step the camera is on during FLY: the same accent, plus a rail on the
leading edge so it reads as "here", not "chosen". */
.data-row-list-item.current {
border-left-color: var(--accent);
border-left-width: 2px;
}
.data-row-list-lead {
min-width: 34px;
flex: none;
opacity: 0.85;
}
.data-row-list-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A list line that states a limit rather than offering an action. */
.data-row-list-item.note {
cursor: default;
opacity: 0.75;
font-style: italic;
}