Merge remote-tracking branch 'upstream/main' into feature/catalonia-cctv-cameras

This commit is contained in:
ulihhhh committed 2026-10-12 12:29:16 +02:00
commit fef7878e54
288 files changed
+45726 -1950

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
streetLevelLayer: 'street-level',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
+18 -2
View File
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
catalog: {
...createLayerCatalog(layers, metadata(layers)),
getSourceAvailability: (id) =>
id === 'one'
? {
available: false,
reason: 'One: data source not configured',
}
: undefined,
},
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.equal(await dataManager.setEnabled('one', true), false);
assert.equal(dataManager.layers.get('one').initialized, false);
assert.equal(
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
'One: data source not configured',
);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
@@ -93,6 +108,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'street-level',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
@@ -101,7 +117,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[12]);
assert.equal(services.localAdsbLayer, layers[13]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+17 -36
View File
@@ -1,3 +1,4 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
@@ -25,38 +26,12 @@ import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
military: ['getSnapshot'],
vessels: ['getSnapshot'],
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
radio: ['getDirectory', 'recordClick'],
traffic: [
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
bikeshare: ['getStations'],
installations: ['getMappedSites', 'searchNearby'],
satellites: ['readGroup'],
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
@@ -83,6 +58,7 @@ export function createApplicationCatalog({
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -90,12 +66,8 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
)
throw new TypeError(`Invalid catalog source: ${name}`);
}
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
@@ -105,7 +77,8 @@ export function createApplicationCatalog({
};
signal.addEventListener('abort', dispose, { once: true });
try {
militaryRegistry.configureSource(sources.military, { signal });
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
@@ -148,6 +121,11 @@ export function createApplicationCatalog({
source: sources['fire-perimeters'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic, surface }),
@@ -155,8 +133,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels,
installations,
createApplicationAwareness({
@@ -197,6 +175,9 @@ export function createApplicationCatalog({
);
return Object.freeze({
...catalog,
getSourceAvailability: sourceComposition.createAvailabilityLookup(
catalog.layers,
),
militaryRegistry,
surface,
weatherClock,
+53 -2
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -39,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 29);
assert.equal(first.layers.length, 30);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
assert.throws(
() =>
createApplicationCatalog({
sources: {},
sources: { flights: {} },
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
eventTarget: null,
});
}
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const sources = fixtureSources([], []);
delete sources.mapillary;
const options = {
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
catalog.get('street-level'),
);
assert.match(
catalog.get('street-level').getUIState().coverage.hint,
/No street-level imagery providers/,
);
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const replacement = createApplicationCatalog({
...options,
streetLevelProviders: [provider],
});
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
assert.deepEqual(replacement.metadata, catalog.metadata);
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
assert.deepEqual(
createApplicationCatalog({
...options,
sources: fixtureSources([], []),
streetLevelProviders: [],
}).get('street-level').providerIds,
[],
);
assert.throws(
() =>
createApplicationCatalog({
...options,
sources: { ...sources, mapillary: {} },
}),
/Mapillary source/,
);
});
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
await dataManager.destroyAll();
+3 -9
View File
@@ -1,12 +1,6 @@
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();
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
}
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
thumbnails: createThumbnailLoader(),
});
}
+32
View File
@@ -0,0 +1,32 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js';
/**
* Inject providers, or use Mapillary only when its source is supplied.
* An explicit provider list replaces the default; unused sources.mapillary is
* neither constructed nor validated, including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = sources.mapillary == null
? []
: [createMapillaryProvider({ source: sources.mapillary })],
}) {
return createStreetLevelLayer({
providers,
services: {
sprites,
picking,
input,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
test('explicit providers replace the default source and an empty list removes it', () => {
const sources = { mapillary: {} };
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const layer = createApplicationStreetLevel({
sources,
providers: [provider],
});
assert.deepEqual(layer.providerIds, ['example']);
assert.deepEqual(
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
[],
);
assert.throws(
() => createApplicationStreetLevel({ sources }),
/Mapillary source/,
);
assert.throws(
() => createApplicationStreetLevel({ providers: null }),
/array/,
);
});
+113
View File
@@ -0,0 +1,113 @@
const contract = (layers, methods) =>
Object.freeze({
layers: Object.freeze(layers),
methods: Object.freeze(methods),
});
/** Acquisition contracts and their consumers; records remain family-specific. */
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
flights: contract(['flights'], ['getSnapshot']),
military: contract(['military'], ['getSnapshot']),
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
cctv: contract(
['cctv'],
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
),
radio: contract(['radio'], ['getDirectory', 'recordClick']),
traffic: contract(
['traffic'],
[
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
),
bikeshare: contract(['bikeshare'], ['getStations']),
installations: contract(
['military-installations'],
['getMappedSites', 'searchNearby'],
),
satellites: contract(['satellites'], ['readGroup']),
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
alpr: contract(['alpr-cameras'], ['fetch']),
firms: contract(['local-firms'], ['getSnapshot']),
wind: contract(['wind'], ['getSnapshot']),
weather: contract(
['weather-radar', 'weather-satellite', 'weather-lightning'],
['getSnapshot'],
),
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
/** An omitted provider is unavailable, never an authoritative empty dataset. */
export class SourceUnavailableError extends Error {
constructor(name) {
super(`Data source not configured: ${name}`);
this.name = 'SourceUnavailableError';
this.code = 'unavailable';
this.unavailable = true;
this.retryable = false;
}
}
/** Validate supplied contracts and keep omitted sources inert during construction. */
export function resolveCatalogSources(supplied = {}) {
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
throw new TypeError('Catalog sources must be an object');
const sources = { ...supplied };
const missing = new Set();
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
const source = sources[name];
if (source == null) {
missing.add(name);
const unavailable = () => {
throw new SourceUnavailableError(name);
};
sources[name] = Object.freeze({
label: name,
...Object.fromEntries(methods.map((method) => [method, unavailable])),
});
} else if (methods.some((method) => typeof source[method] !== 'function')) {
throw new TypeError(`Invalid catalog source: ${name}`);
}
}
return {
sources,
isConfigured: (name) => !missing.has(name),
createAvailabilityLookup(layers) {
const byId = new Map(layers.map((layer) => [layer.id, layer]));
const availability = new Map();
for (const [name, { layers: ids }] of Object.entries(
CATALOG_SOURCE_CONTRACTS,
)) {
for (const id of ids) {
const layer = byId.get(id);
if (!layer) throw new Error(`Missing source consumer: ${id}`);
availability.set(
id,
Object.freeze({
available: !missing.has(name),
source: name,
reason: missing.has(name)
? `${layer.name || id}: data source not configured`
: null,
}),
);
}
}
return (id) =>
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCatalogSources,
CATALOG_SOURCE_CONTRACTS,
} from './sourceComposition.js';
import { createApplicationCatalog } from './constructCatalog.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function catalog(sources, t) {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
});
}
test('every acquisition source can be omitted without losing catalog membership', (t) => {
const baseline = catalog(createStandaloneLayerSources(), t);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
const sources = createStandaloneLayerSources({ [name]: null });
const actual = catalog(sources, t);
assert.deepEqual(actual.metadata, baseline.metadata, name);
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
assert.equal(actual.getSourceAvailability(id).available, false, id);
}
}
});
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
for (const layer of actual.layers) manager.register(layer);
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
for (const id of ids) {
assert.equal(await manager.setEnabled(id, true), false, id);
assert.equal(manager.layers.get(id).initialized, false, id);
const row = manager.getAll().find((entry) => entry.id === id);
assert.equal(row.enabled, false);
assert.equal(row.stats.status, 'unavailable');
assert.match(row.stats.error, /data source not configured/);
}
}
});
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
const source = { getSnapshot: async () => [] };
assert.equal(
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
source,
);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
assert.throws(
() => resolveCatalogSources({ [name]: {} }),
/Invalid catalog source/,
);
}
const missing = resolveCatalogSources({}).sources.earthquakes;
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
});
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
const source = { getSnapshot: async () => [] };
const sources = createStandaloneLayerSources({
flights: source,
military: null,
});
assert.equal(sources.flights, source);
assert.equal(sources.military, null);
assert.equal(typeof sources.cctv.getCatalog, 'function');
assert.throws(
() => createStandaloneLayerSources({ fligts: source }),
/Unknown source/,
);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('street-level'));
assert.equal(await manager.setEnabled('street-level', true), false);
assert.equal(manager.layers.get('street-level').initialized, false);
const row = manager.getAll()[0];
assert.equal(row.stats.status, 'unavailable');
assert.equal(row.stats.error, 'No street-level imagery providers configured');
assert.equal(await manager.setEnabled('street-level', false), true);
const configured = catalog(createStandaloneLayerSources(), t);
assert.equal(
configured.get('street-level').getSourceAvailability().available,
true,
);
});
test('availability lookup accepts frozen layers and uses their display names', () => {
const composition = resolveCatalogSources({});
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
.flatMap(({ layers }) => layers)
.map((id) =>
Object.freeze({
id,
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
}),
);
const lookup = composition.createAvailabilityLookup(layers);
assert.equal(
lookup('fire-perimeters').reason,
'Fire perimeters: data source not configured',
);
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
assert.equal(
manager.getAll()[0].stats.error,
'Fire perimeters: data source not configured',
);
});
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected fallback acquisition');
});
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
const registry = actual.militaryRegistry;
assert.equal(actual.getSourceAvailability('flights').available, true);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(registry.isMilitaryIcao('abc123'), false);
registry.registerMilitaryIcaos([' ABC123 ']);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
assert.equal(registry.isMilitaryIcao('def456'), false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('military'));
assert.equal(await manager.setEnabled('military', true), false);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(fetches, 0);
});
+273
View File
@@ -0,0 +1,273 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
preferCodexOAuth,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
const nowMs = Date.UTC(2026, 8, 27);
const jwt = (payload) =>
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
const auth = (token, extras = {}) =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
...extras,
});
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
assert.equal(preferCodexOAuth({}), false);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
assert.throws(
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
/must be true or false/,
);
});
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
home: '/home/fixture',
}),
path.resolve('/tmp/custom-auth.json'),
);
assert.equal(
codexAuthPath({
environment: { CODEX_HOME: '/tmp/codex-home' },
home: '/home/fixture',
}),
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
);
assert.equal(
codexAuthPath({ environment: {}, home: '/home/fixture' }),
path.join('/home/fixture', '.codex', 'auth.json'),
);
});
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
const current = jwt({ exp: nowMs / 1000 + 3600 });
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(current),
}),
current,
);
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
}),
current,
'GEV does not require or consume the vendor refresh token',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => auth(current, { auth_mode: 'apikey' }),
() =>
JSON.stringify({
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() => {
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
});
},
(error) => {
assert.match(error.message, /sign-in has expired/);
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
return true;
},
);
}
const noExp = jwt({ sub: 'fixture' });
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
assert.equal(
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs }),
}),
noExp,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
}),
/sign-in has expired/,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => {
throw new Error('missing');
},
}),
/sign-in is unavailable/,
);
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
);
assert.equal(checked.length, 1);
const child = new EventEmitter();
let unref = 0;
child.unref = () => {
unref += 1;
};
const calls = [];
const pending = startCodexChatGptLogin({
executable: '/fixture/codex',
home: '/home/fixture',
environment: {
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
OPENAI_API_KEY: 'fixture-api-key',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
},
spawnImpl(command, args, options) {
calls.push({ command, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
const login = await pending;
assert.equal(unref, 1);
assert.equal(calls[0].command, '/fixture/codex');
assert.deepEqual(calls[0].args, ['login']);
assert.equal(calls[0].options.detached, true);
assert.equal(calls[0].options.stdio, 'ignore');
assert.deepEqual(
calls[0].options.env,
{
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
CODEX_HOME: path.join('/home/fixture', '.codex'),
},
'provider secrets stay out of the login process',
);
child.emit('exit', 1);
assert.deepEqual(await login.completion, { exitCode: 1 });
});
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
await assert.rejects(
startCodexChatGptLogin({
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
home: '/home/fixture',
executable: 'codex',
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
}),
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
);
});
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
const child = new EventEmitter();
const environment = { CODEX_HOME: './fixture-codex' };
const login = await startCodexChatGptLogin({
environment,
executable: 'codex',
spawnImpl(_command, _args, options) {
assert.equal(
options.env.CODEX_HOME,
path.resolve(environment.CODEX_HOME),
);
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.equal(
environment.CODEX_HOME,
'./fixture-codex',
'caller environment is unchanged',
);
child.emit('exit', 0);
assert.deepEqual(await login.completion, { exitCode: 0 });
});
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
child.emit('error', new Error('private-token-fixture')),
);
return child;
},
}),
(error) =>
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
!error.message.includes('private-token-fixture'),
);
const running = new EventEmitter();
let cancelled = 0;
running.kill = (signal) => {
assert.equal(signal, 'SIGTERM');
cancelled += 1;
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
return running;
},
});
login.cancel();
assert.equal(cancelled, 1);
assert.deepEqual(await login.completion, { exitCode: null });
});
+879 -144
View File
File diff suppressed because it is too large. Load diff
+374 -7
View File
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
const eng = makeEngine();
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
const first = await eng.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const r = await eng.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: true }],
});
assert.equal(r.count, 1);
assert.equal(r.items[0].id, 'GND1');
assert.equal(r.coverage.followUp, true);
assert.equal(r.scopeLabel, first.scopeLabel);
assert.equal(r.display.scope, first.display.scope);
assert.equal(r.coverage.scope, first.coverage.scope);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: region lookup receives and obeys the owning action signal', async () => {
const controller = new AbortController();
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
return null;
},
getViewContext: () => ({
lat: 30.27,
lon: -97.74,
viewRadiusKm: 150,
}),
});
const pending = eng.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await Promise.resolve();
controller.abort();
const result = await pending;
assert.equal(receivedSignal, controller.signal);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -262,20 +379,132 @@ test('helpers: haversine sanity + scope radius', () => {
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
test('capped cohorts report returned/total/truncated and keep it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getRecordCoverage: () => ({ total: 3000, truncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const layer = result.coverage.layersQueried[0];
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
assert.deepEqual(result.coverage.records, { returned: 1, total: 3000, truncated: true });
assert.match(result.display.caveat, /counted 1 of 3,000 loaded records/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
assert.equal(followUp.coverage.layersQueried[0].total, 3000);
});
test('an uncapped cohort is complete and carries no caveat', async () => {
const result = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' } });
assert.deepEqual(result.coverage.records, { returned: 4, total: 4, truncated: false });
assert.equal(result.display.caveat, undefined);
assert.equal(result.display.scope, 'anywhere in the loaded data');
});
test('unknown fields, operators, scopes and wrong-typed values are refused with the allowed values', async () => {
const engine = makeEngine();
const field = await engine.query({ layers: ['flights'], filters: [{ field: 'time', op: 'gte', value: 5 }] });
assert.equal(field.ok, false);
assert.equal(field.code, 'UNKNOWN_FIELD');
assert.ok(field.allowed.includes('altitudeM'));
const op = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'wat', value: 5 }] });
assert.equal(op.code, 'BAD_OPERATOR');
assert.deepEqual(op.allowed, ['gt', 'gte', 'lt', 'lte', 'eq', 'neq']);
const flag = await engine.query({ layers: ['flights'], filters: [{ field: 'military', op: 'eq', value: 'false' }] });
assert.equal(flag.code, 'BAD_VALUE', 'the string "false" must not match the TRUE record');
const num = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 'high' }] });
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
});
test('a typed boolean filter still matches exactly', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'military', op: 'eq', value: false }] });
assert.deepEqual(r.items.map((i) => i.id).sort(), ['GND1', 'N123', 'SWA1']);
});
test('distance honours sortDir: desc is farthest first', async () => {
const far = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', sortDir: 'desc', limit: 1 });
assert.equal(far.items[0].id, 'N123');
const near = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'distance', limit: 1 });
assert.equal(near.items[0].id, 'SWA1');
});
test('a follow-up without a previous answer is refused, not a fresh flights query', async () => {
const r = await makeEngine().query({ followUp: true, filters: [] });
assert.equal(r.ok, false);
assert.equal(r.code, 'NO_RESULT_CONTEXT');
});
test('follow-ups never rewrite the remembered rows', async () => {
const engine = makeEngine();
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
await engine.query({ followUp: true, sortBy: 'distance' });
const again = await engine.query({ followUp: true });
assert.equal(again.count, 4, 'a follow-up keeps the remembered set, not the view');
assert.ok(again.items.every((item) => item.distanceKm === undefined));
});
test('a superseded slow region query cannot overwrite the newer follow-up memory', async () => {
let releaseSlow;
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS }[key] || []),
resolveRegionRing: () => new Promise((resolve) => { releaseSlow = () => resolve(TEXLAND); }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
let generation = 0;
const slowGeneration = ++generation;
const slow = engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, { isCurrent: () => generation === slowGeneration });
const newGeneration = ++generation;
const fresh = await engine.query({ layers: ['ais-live-vessels'], scope: { kind: 'anywhere' } }, { isCurrent: () => generation === newGeneration });
assert.equal(fresh.ok, true);
releaseSlow();
const stale = await slow;
assert.equal(stale.code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((i) => i.layerKey), ['ais-live-vessels', 'ais-live-vessels']);
});
test('off, loading and unavailable layers are refused distinctly; a real zero stays ok', async () => {
const engineFor = (snapshot, rows = [], warming = false) => createAnalystEngine({
getRecords: () => rows,
getLayerSnapshot: () => snapshot,
isWarming: () => warming,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
assert.equal((await engineFor({ enabled: false, feedState: 'off' }).query({ layers: ['earthquakes'] })).code, 'LAYER_OFF');
assert.equal((await engineFor({ enabled: true, feedState: 'loading' }).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'nominal' }, [], true).query({ layers: ['earthquakes'] })).code, 'NOT_READY');
assert.equal((await engineFor({ enabled: true, feedState: 'unavailable' }).query({ layers: ['earthquakes'] })).code, 'FEED_UNAVAILABLE');
const zero = await engineFor({ enabled: true, feedState: 'nominal' }, [{ id: 'q', lat: 50, lon: 50, magnitude: 2 }]).query({ layers: ['earthquakes'], filters: [{ field: 'magnitude', op: 'gte', value: 5 }] });
assert.equal(zero.ok, true);
assert.equal(zero.count, 0);
assert.equal(zero.coverage.layersQueried[0].status, 'ok');
});
test('time fields filter by ISO and derive ageHours; the feed window is exposed', async () => {
const now = Date.parse('2026-09-23T12:00:00Z');
const engine = createAnalystEngine({
now: () => now,
getRecords: () => [
{ id: 'recent', lat: 0, lon: 0, magnitude: 4, timeMs: now - 2 * 3_600_000 },
{ id: 'older', lat: 0, lon: 0, magnitude: 5, timeMs: now - 20 * 3_600_000 },
],
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const iso = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'timeMs', op: 'gte', value: '2026-09-23T06:00:00Z' }] });
assert.deepEqual(iso.items.map((i) => i.id), ['recent']);
const age = await engine.query({ layers: ['earthquakes'], scope: { kind: 'anywhere' }, filters: [{ field: 'ageHours', op: 'lte', value: 6 }] });
assert.deepEqual(age.items.map((i) => i.id), ['recent']);
assert.equal(age.items[0].ageHours, 2);
assert.equal(age.display.window, 'last 24 h');
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
@@ -293,3 +522,141 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
const rows = Array.from({ length: 200_000 }, (_, i) => ({ id: `FIRE-${i}`, lat: 0, lon: 0, frp: i }));
const engine = createAnalystEngine({
getRecords: () => rows,
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
assert.equal(result.count, 200_000);
assert.equal(result.items[0].frp, 199_999);
assert.equal(result.summary.frpMin, 0);
});
test('an enabled but empty layer says why on screen', async () => {
const engine = createAnalystEngine({
getRecords: () => [],
getLayerSnapshot: () => ({ enabled: true, feedState: 'nominal' }),
getEmptyNote: () => 'Fly below 400 km to a covered region',
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['transit'] });
assert.equal(result.ok, true);
assert.equal(result.count, 0);
assert.equal(result.coverage.layersQueried[0].status, 'empty');
assert.match(result.display.caveat, /transit: Fly below 400 km/);
});
test('a radius around a named place without a centre is refused, not measured around the camera', async () => {
const r = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Singapore', km: 200 } });
assert.equal(r.ok, false);
assert.equal(r.code, 'BAD_SCOPE');
const ok = await makeEngine().query({ layers: ['ais-live-vessels'], scope: { kind: 'radius', name: 'Houston', km: 200, center: { lat: 29.6, lon: -95.0 } } });
assert.equal(ok.ok, true);
});
test('a field copied with its hinted unit still resolves', async () => {
const r = await makeEngine().query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)', filters: [{ field: 'frp (MW)', op: 'gt', value: 100 }] });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id), ['FIRE-3', 'FIRE-1']);
});
test('a written unit converts to the field unit or is refused', async () => {
const engine = makeEngine();
const feet = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM(ft)', op: 'gt', value: 40000 }] });
assert.equal(feet.ok, true);
assert.deepEqual(feet.items.map((i) => i.id), ['RCH01'], '40,000 ft is 12,192 m, not 40,000 m');
const same = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM (m)', op: 'gt', value: 12192 }] });
assert.deepEqual(same.items.map((i) => i.id), ['RCH01']);
const knots = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'speedMps(kts)', op: 'gt', value: 470 }] });
assert.deepEqual(knots.items.map((i) => i.id), ['RCH01'], '470 kn is ~242 m/s');
const wrong = await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM(kts)', op: 'gt', value: 1 }] });
assert.equal(wrong.code, 'BAD_UNIT');
const unitless = await engine.query({ layers: ['flights'], filters: [{ field: 'callsign(ft)', op: 'eq', value: 'X' }] });
assert.equal(unitless.code, 'BAD_UNIT');
const hinted = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp(MW)' });
assert.equal(hinted.ok, true);
});
test('unambiguous numeric strings are numbers; blanks, booleans and words are not', async () => {
const engine = makeEngine();
const text = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, filters: [{ field: 'altitudeM', op: 'gt', value: '12192' }] });
assert.equal(text.ok, true);
assert.deepEqual(text.items.map((i) => i.id), ['RCH01']);
for (const value of ['', ' ', true, 'high', '12k'])
assert.equal((await engine.query({ layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value }] })).code, 'BAD_VALUE', JSON.stringify(value));
});
test('any supplied centre must be a real coordinate', async () => {
const engine = makeEngine();
for (const center of [{ lat: 0 }, { lat: 999, lon: 0 }, { lat: 10, lon: 200 }, { lat: '10', lon: 20 }])
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center } })).code, 'BAD_SCOPE', JSON.stringify(center));
assert.equal((await engine.query({ layers: ['flights'], scope: { kind: 'radius', km: 100, center: { lat: 30.2, lon: -97.7 } } })).ok, true);
});
test('mixed off/loading/unavailable layers refuse; a partly answerable query says so', async () => {
const snapshots = { earthquakes: { enabled: true, feedState: 'unavailable' }, 'local-firms': { enabled: true, feedState: 'loading' }, flights: { enabled: false, feedState: 'off' } };
const engine = createAnalystEngine({
getRecords: (key) => (key === 'ais-live-vessels' ? SHIPS : []),
getLayerSnapshot: (key) => snapshots[key] || { enabled: true, feedState: 'nominal' },
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const none = await engine.query({ layers: ['earthquakes', 'local-firms', 'flights'] });
assert.equal(none.ok, false);
assert.equal(none.code, 'NOT_READY');
assert.deepEqual(none.layerStatus.map((l) => l.status), ['error', 'loading', 'off']);
assert.equal(engine.hasMemory(), false, 'a refusal never becomes follow-up memory');
const down = await engine.query({ layers: ['earthquakes', 'flights'] });
assert.equal(down.code, 'FEED_UNAVAILABLE');
const partial = await engine.query({ layers: ['ais-live-vessels', 'earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(partial.ok, true);
assert.equal(partial.partial, true);
assert.deepEqual(partial.unanswered, ['earthquakes']);
assert.match(partial.display.caveat, /earthquakes error/);
});
test('returned items are copies: mutating them cannot change the remembered answer', async () => {
const engine = makeEngine();
const first = await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' }, sortBy: 'altitudeM' });
for (const item of first.items) {
item.altitudeM = -1;
item.id = 'MUTATED';
}
const again = await engine.query({ followUp: true, sortBy: 'altitudeM' });
assert.deepEqual(again.items.map((i) => i.id), ['RCH01', 'SWA1', 'N123', 'GND1']);
assert.equal(FLIGHTS[1].altitudeM, 13500, 'the provider rows are untouched');
});
test('ranking 250k rows stays within a budget and yields to the page', async () => {
const rows = Array.from({ length: 250_000 }, (_, i) => ({ id: `F${i}`, lat: (i % 170) - 85, lon: (i % 350) - 175, frp: (i * 7919) % 100_003 }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const started = performance.now();
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'frp', limit: 1 });
const elapsed = performance.now() - started;
clearInterval(timer);
assert.equal(result.count, 250_000);
assert.equal(result.items[0].frp, 100_002);
assert.ok(elapsed < 400, `took ${Math.round(elapsed)} ms`);
assert.ok(ticks > 0, 'the scan yielded between slices');
let current = true;
const pending = engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, sortBy: 'distance' }, { isCurrent: () => current });
current = false;
assert.equal((await pending).code, 'CANCELLED', 'a superseded scan stops at the next slice');
});
test('analyst: a follow-up naming other layers than the last answer is refused', async () => {
const engine = createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
await engine.query({ layers: ['flights'], scope: { kind: 'anywhere' } });
const r = await engine.query({ layers: ['local-firms'], followUp: true });
assert.equal(r.ok, false);
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { createDirectionsSource } from '../layers/directions/source.js';
/**
* @module directions
* @description Service bundle for the Directions layer.
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
}
export {
+1
View File
@@ -25,6 +25,7 @@ test('firms analyst record: full record maps every contract field', () => {
frp: 1520.4,
confidence: 0.9,
satellite: 'N21',
sensor: 'VIIRS',
acqTime: 1_753_600_000_000,
});
});
+1069 -236
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
export function patchRequestedIdSwitches(previous, applied, requested) {
const current = decodeIdSwitches(applied);
const next = new Map(
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
);
let changed = false;
for (const [id, on] of current) {
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
next.set(id, on);
changed = true;
}
}
return changed ? encodeIdSwitches(next) : undefined;
}
+28
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -410,6 +411,27 @@ const OPTION_GROUPS = Object.freeze({
),
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
Object.freeze({
key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r',
defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
flat: 'f',
}),
// Relative days, so a link means the same next year. The max matches
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -631,6 +653,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'satellites',
}),
Object.freeze({
id: 'street-level',
token: '0',
disposition: 'enabled+options',
optionOwner: 'street-level',
}),
Object.freeze({
id: 'telegeography-submarine-cables',
token: 'u',
+172 -27
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
@@ -26,6 +27,8 @@ import {
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
function deferred() {
let resolve;
@@ -199,8 +202,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, 28);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
assert.equal(REGISTERED_LAYER_IDS.length, 29);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -239,15 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '0');
assert.equal(nextLayerStateToken(), '3');
assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
'4',
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
}),
'5',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -274,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
[
...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
[...LAYER_STATE_TOKEN_ALPHABET].map(
(second) => `${first}${second}`,
),
),
].map((token, index) => [`occupied-${index}`, token]),
),
@@ -296,42 +305,46 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true,
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
),
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
}),
true,
);
assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 0/,
() =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
assert.equal(
validateLayerStateAllocations(
beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
),
validateLayerStateAllocations(beforeLastDigit, {
...beforeLastDigit,
futurePair: '00',
futureDigit: '9',
}),
true,
);
assert.equal(
validateLayerStateAllocations(
digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' },
),
validateLayerStateAllocations(digitsExhausted, {
...digitsExhausted,
pairB: '01',
pairA: '00',
}),
true,
);
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2642,3 +2655,135 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
decoded,
);
});
/** Street Level options decoded from a v2 link with these `lo` assignments. */
function streetLevelOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', '0'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['street-level'];
}
test('Street Level: every option round-trips through a share link and stored state', () => {
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'street-level'
],
state.options['street-level'],
);
});
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
// A window past MAX_SINCE_DAYS reads as "any date".
assert.equal(
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
MAX_SINCE_DAYS,
);
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
for (const { days } of SINCE_STOPS)
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
});
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal(
createDefaultLayerState().options['street-level'].mapillary,
true,
);
});
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
const manager = new DataLayerManager({});
const providers = new Map([
['mapillary', true],
['alpha', true],
['beta', true],
]);
const filter = { pano: 'all', sinceDays: 0 };
for (const id of REGISTERED_LAYER_IDS) {
manager.register(
id === 'street-level'
? {
...fakeLayer(id),
getParams: () => encodeParams({ providers, filter }),
setParams(params) {
const decoded = decodeParams(params, {
providerIds: providers.keys(),
filter,
});
for (const [id, on] of decoded.providers) providers.set(id, on);
return true;
},
}
: fakeLayer(id),
);
}
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
try {
providers.set('beta', false); // Passive state does not belong to the next action.
assert.equal(
manager.setLayerParams(
'street-level',
{ alpha: false },
{ origin: 'user' },
),
true,
);
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
const restored = decodeParams(shared.options['street-level'], {
providerIds: ['beta', 'alpha'],
filter,
});
assert.deepEqual([...restored.providers], [['alpha', false]]);
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
manager.setLayerParams(
'street-level',
{ alpha: true },
{ origin: 'local-restore' },
);
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
} finally {
coordinator.destroy();
}
});
+16 -2
View File
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
// Packed options can persist only the subkeys owned by this request.
const requestedValue =
explicitlyRequested || implicitTrackingSync
? change.params[spec.key]
: spec.patchRequested?.(
nextOwnerOptions[spec.key],
change.params[spec.key],
requestedParams,
);
if (
!explicitlyRequested &&
!implicitTrackingSync &&
requestedValue === undefined
)
continue;
const value = spec.normalize(requestedValue);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
+35 -29
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
@@ -122,10 +128,10 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -145,9 +151,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
.future,
'0',
'3',
);
assert.throws(
() =>
@@ -165,14 +171,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '0']] },
{ rows: [...reservationRows, ['retired-layer', '3']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
assert.equal(nextLayerStateToken(published), '4');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '3',
newcomer: '4',
}),
true,
);
@@ -180,15 +186,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '0',
competing: '3',
}),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '0',
newcomer: '3',
}),
/changed or removed: retired-layer/,
);
@@ -196,8 +202,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -218,8 +224,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '0']],
[{ id: 'pr-b', token: '0' }],
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -227,10 +233,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '0' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
],
);
const staleRebase = runCheck();
@@ -238,16 +244,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 3/);
assert.match(helper.stdout, /pr-b: 4/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -255,20 +261,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /29 published, 1 new/);
assert.match(corrected.stdout, /30 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -320,7 +326,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'03456789'].map((digit) => [
const priorDigits = [...'3456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -357,7 +363,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 67);
assert.equal(params.get('l').length, 69);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -375,7 +381,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 29);
assert.equal(Object.keys(published).length, 30);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -19,6 +19,7 @@
["recent-imagery", "1"],
["rocket-launches", "x"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
["traffic", "t"],
["transit", "j"],
+23 -1
View File
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
* for real-time data overlays on the CesiumJS globe.
*/
export class LayerLifecycle {
constructor(viewer, { allowQaRegistration = false } = {}) {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -228,6 +232,7 @@ export class LayerLifecycle {
_normalizedStats(entry) {
const moduleStats = this._moduleStats(entry);
const availability = this._sourceAvailability(entry.module.id);
const lifecycleLoading =
entry.lifecycleState === 'enabling' ||
entry.lifecycleState === 'disabling';
@@ -235,6 +240,13 @@ export class LayerLifecycle {
count: 0,
lastUpdate: null,
...moduleStats,
...(availability?.available === false
? {
status: 'unavailable',
sourceUnavailable: true,
error: availability.reason,
}
: {}),
loading: lifecycleLoading || moduleStats.loading === true,
refreshing: entry.refreshing || moduleStats.refreshing === true,
managerRefreshError: entry.managerRefreshError,
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
}
}
_sourceAvailability(layerId) {
return (
this._catalogSourceAvailability?.(layerId) ??
this.layers.get(layerId)?.module.getSourceAvailability?.()
);
}
async _visibilityBlockReason(change) {
const availability = this._sourceAvailability(change.layerId);
if (change.enabled && availability?.available === false)
return availability.reason || 'Data source unavailable';
for (const callback of this._visibilityGuards) {
try {
const result = await callback(change);
+65
View File
@@ -41,6 +41,7 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
'military-installations',
];
test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
'At least 20,000',
);
assert.equal(
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
'19,999',
);
assert.equal(formatAwarenessCount({ count: null }), '?');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
@@ -2554,6 +2567,7 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.flights, 42);
assert.equal(window.military, 13);
assert.equal(window.aircraft, 55);
assert.equal(window.complete, true);
assert.equal(window.centeredOn, 'ASA635');
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
// A feed that cannot answer says so rather than reporting a confident zero.
@@ -2561,6 +2575,57 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.vessels, 'unknown');
});
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
const restoreCollections = stubAwarenessCollections({
flights: [
{
icao24: 'subject',
id: 'SUBJECT',
position: subjectPosition,
distance: 0,
},
{
icao24: 'nearby',
id: 'NEARBY',
position: nearbyPosition,
distance: 5_000,
},
],
});
const runtime = installAwarenessRuntime();
try {
runtime.dispatch(
'gev:awareness-subject-selected',
awarenessSubject('flights', 'subject', subjectPosition),
);
await nextTurn();
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
assert.equal(first.cohorts.flights.count, 1);
assert.equal(first.cohorts.flights.complete, true);
assert.equal(first.cohorts.flights.provenance.id, 'flights');
assert.equal(first.cohorts.flights.provenance.enabled, true);
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
first.cohorts.flights.items[0].icao24 = 'mutated';
first.cohorts.flights.items[0].position.x = 99;
first.cohorts.flights.provenance.source = 'mutated';
first.cohorts.flights.provenance.stats.coverage = { radiusM: 1 };
first.subject.position.x = 77;
const second = militaryAwarenessLayer.getAircraftQuerySnapshot();
assert.equal(second.cohorts.flights.items[0].icao24, 'nearby');
assert.equal(second.cohorts.flights.items[0].position.x, nearbyPosition.x);
assert.notEqual(second.cohorts.flights.provenance.source, 'mutated');
assert.notDeepEqual(second.cohorts.flights.provenance.stats.coverage, {
radiusM: 1,
});
assert.equal(second.subject.position.x, subjectPosition.x);
} finally {
runtime.restore();
restoreCollections();
}
});
test('there is no contacts window without a subject', () => {
assert.equal(contactsWindowFromSnapshot(null), null);
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
+17
View File
@@ -65,6 +65,23 @@ export function unregisterPickOwner(layerId) {
_owners.delete(layerId);
}
/**
* The registered layer that owns a picked id, or null.
* @param {string} pickedId - Canonical pick id from {@link resolvePickId}.
* @returns {string|null} Owning layer id.
*/
export function findPickOwner(pickedId) {
if (!pickedId) return null;
for (const [ownerId, predicate] of _owners) {
try {
if (predicate(pickedId)) return ownerId;
} catch {
// a broken predicate must never break picking
}
}
return null;
}
/**
* True when some OTHER registered layer owns the picked id.
* @param {string} layerId - The asking layer's id (excluded from the scan).
+128
View File
@@ -0,0 +1,128 @@
import { getContextStore, isContextRecordActive } from './contextStore.js';
import { findPickOwner, resolvePickId } from './pickRegistry.js';
/**
* Resolve a `scene.pick()` result to the identity a person would name: which
* layer, which record, and its display label.
*
* Two lanes, in order:
* 1. Context-store carriers. Entity-backed layers (infrastructure GeoJSON,
* selected vessels, fires, installations) stamp `__gevContextId` on the
* picked object, and the store record already carries the label and
* position voice uses everywhere else.
* 2. Pick owners. Primitive-backed layers (aircraft, satellites, vessels)
* register an ownership predicate in `pickRegistry`; the owning layer's
* `findByQuery` turns the pick id into a live descriptor.
*
* Trails claim their pick ids only to make clicks inert, so they never
* resolve. Disabled layers never resolve: what the user cannot see is not
* what they are pointing at.
*/
const KIND_BY_LAYER = Object.freeze({
flights: 'aircraft',
military: 'aircraft',
'local-adsb': 'aircraft',
'ais-live-vessels': 'vessel',
satellites: 'satellite',
'local-firms': 'fire',
});
const IGNORED_OWNERS = new Set(['trails']);
function text(value) {
return String(value ?? '').trim();
}
function finite(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
/** Kind of thing a layer holds, for deixis ("this aircraft", "this place"). */
export function pickedKindForLayer(layerId) {
return KIND_BY_LAYER[layerId] || 'feature';
}
function contextCarrierRecord(picked, dataManager) {
const store = getContextStore();
for (const carrier of [
picked?.id,
picked?.primitive?.id,
picked?.primitive,
]) {
const contextId =
carrier && typeof carrier === 'object' ? carrier.__gevContextId : null;
if (!contextId) continue;
const record = store.entities.get(contextId);
if (record && isContextRecordActive(record, dataManager)) return record;
}
return null;
}
function fromRecord(record) {
const layerId = record.layerId || null;
const id =
layerId === 'ais-live-vessels' && record.properties?.mmsi
? String(record.properties.mmsi)
: String(record.id);
return {
layerId,
id,
label:
text(record.label) ||
text(record.properties?.name) ||
text(record.layerName) ||
id,
kind: pickedKindForLayer(layerId),
lat: finite(record.latitude),
lon: finite(record.longitude),
};
}
function descriptorLabel(found, fallback) {
return (
text(found?.callsign) ||
text(found?.registration) ||
text(found?.name) ||
text(found?.icao24) ||
text(found?.mmsi) ||
text(found?.noradId) ||
fallback
);
}
/**
* @param {object|null} picked Result of `scene.pick()`.
* @param {object} [options]
* @param {object} [options.dataManager] Layer lifecycle (enabled state, modules).
* @returns {{layerId: string, id: string, label: string, kind: string, lat: number|null, lon: number|null, altitudeM?: number|null}|null}
*/
export function resolvePickedContext(picked, { dataManager = null } = {}) {
if (!picked) return null;
const record = contextCarrierRecord(picked, dataManager);
if (record) return fromRecord(record);
const pickId = resolvePickId(picked);
if (!pickId) return null;
const layerId = findPickOwner(pickId);
if (!layerId || IGNORED_OWNERS.has(layerId)) return null;
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
const layer = dataManager?.layers?.get?.(layerId);
let found = null;
try {
found = layer?.module?.findByQuery?.(pickId) || null;
} catch {
found = null;
}
return {
layerId,
id: pickId,
label: descriptorLabel(found, text(layer?.name) || pickId),
kind: pickedKindForLayer(layerId),
lat: finite(found?.latitude),
lon: finite(found?.longitude),
altitudeM: finite(found?.altitudeM),
};
}
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
globalThis.window ||= globalThis;
globalThis.CustomEvent ||= class CustomEvent {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
globalThis.dispatchEvent ||= () => true;
const { registerEntityContext } = await import('./contextStore.js');
const { registerPickOwner, unregisterPickOwner, findPickOwner } = await import('./pickRegistry.js');
const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js');
function manager({ enabled = new Set(['flights', 'local-datacenters']), modules = {} } = {}) {
return {
isEnabled: (id) => enabled.has(id),
layers: new Map(Object.entries(modules).map(([id, module]) => [id, { name: id, module }])),
};
}
test('an aircraft billboard resolves through its pick owner to a live label', () => {
registerPickOwner('flights', (id) => id === 'a1b2c3');
const dataManager = manager({
modules: {
flights: {
findByQuery: (q) => (q === 'a1b2c3' ? { icao24: 'a1b2c3', callsign: 'UPS793', latitude: 30.2, longitude: -97.7, altitudeM: 9000 } : null),
},
},
});
const picked = { id: 'a1b2c3', primitive: {} };
assert.equal(findPickOwner('a1b2c3'), 'flights');
assert.deepEqual(resolvePickedContext(picked, { dataManager }), {
layerId: 'flights',
id: 'a1b2c3',
label: 'UPS793',
kind: 'aircraft',
lat: 30.2,
lon: -97.7,
altitudeM: 9000,
});
unregisterPickOwner('flights');
});
test('an entity-backed feature resolves through the context store', () => {
const entity = { show: true };
registerEntityContext(entity, {
id: 'local-datacenters:7',
layerId: 'local-datacenters',
label: 'Data Center 7',
latitude: 30.1,
longitude: -97.6,
});
const out = resolvePickedContext({ id: entity }, { dataManager: manager() });
assert.deepEqual(out, {
layerId: 'local-datacenters',
id: 'local-datacenters:7',
label: 'Data Center 7',
kind: 'feature',
lat: 30.1,
lon: -97.6,
});
});
test('disabled layers, trails and empty picks never resolve', () => {
registerPickOwner('military', (id) => id === 'm1');
assert.equal(resolvePickedContext({ id: 'm1' }, { dataManager: manager() }), null, 'military is disabled');
unregisterPickOwner('military');
assert.equal(resolvePickedContext({ id: 'gev-trail:1' }, { dataManager: manager() }), null);
assert.equal(resolvePickedContext(null), null);
assert.equal(resolvePickedContext({ id: 'unknown' }, { dataManager: manager() }), null);
assert.equal(pickedKindForLayer('ais-live-vessels'), 'vessel');
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const layer = createDirectionsLayer({ services: {
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
+10 -5
View File
@@ -656,7 +656,7 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping is instructions only', () => {
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
@@ -667,12 +667,17 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
const block = JSON.stringify(legacyTools);
// Re-derived for the additive `local-adsb` set_layer_visibility value and
// its common-name mapping; the missions still ride existing tools.
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
// Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt
// consolidation (shorter analyst, annotate_map and ISS wording), plus the
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level adds toggle enum values and the generated alias hint.
assert.equal(block.length, 29802, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d',
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+179
View File
@@ -1,4 +1,8 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import {
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './voice/cloudVoiceAuth.js';
/**
* The POWER UP surface — paste a key, get a power.
@@ -38,6 +42,52 @@ export function collectKeyUpdates(fields) {
return updates;
}
function sleepWithSignal(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function waitForChatGptOAuth({
fetchImpl,
signal,
timeoutMs = 120_000,
pollMs = 1_000,
now = () => Date.now(),
sleep = sleepWithSignal,
} = {}) {
const deadline = now() + Math.max(0, timeoutMs);
while (!signal?.aborted && now() <= deadline) {
const response = await fetchImpl('/api/realtime/oauth-status', {
cache: 'no-store',
signal,
});
const payload = await response.json().catch(() => ({}));
if (signal?.aborted) return false;
if (response.ok && payload.available) return true;
if (!response.ok || payload.loginFailed) {
throw new Error(
payload.error || 'Could not check ChatGPT sign-in. Try again.',
);
}
if (now() >= deadline) break;
await sleep(Math.max(0, pollMs), signal);
}
return false;
}
/**
* After the FIRST Google key lands, the restart's reload should boot the
* photoreal default — not faithfully restore the auto-selected keyless OSM
@@ -61,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
function updateCloudVoiceAuthControl(auth, busy = false) {
const mode = readStoredCloudVoiceAuthMode();
const state = auth.querySelector('.key-setup-cloud-auth-state');
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
state.textContent =
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
toggle.title =
mode === 'oauth'
? 'Use OPENAI_API_KEY for the next cloud voice session'
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
// Keep the control focusable while the delegated handler blocks repeats.
toggle.setAttribute('aria-disabled', String(busy));
}
/** Build one key row. All content is our own registry text, set via textContent. */
function buildRow(documentRef, key) {
const row = documentRef.createElement('section');
@@ -144,6 +209,19 @@ function buildRow(documentRef, key) {
}
row.append(fields);
}
if (key.id === 'openai') {
const auth = documentRef.createElement('div');
auth.className = 'key-setup-cloud-auth';
const state = documentRef.createElement('span');
state.className = 'key-setup-cloud-auth-state';
const toggle = documentRef.createElement('button');
toggle.type = 'button';
toggle.className = 'key-setup-cloud-auth-toggle';
toggle.dataset.cloudVoiceAuthToggle = 'true';
auth.append(state, toggle);
updateCloudVoiceAuthControl(auth);
row.append(auth);
}
return row;
}
@@ -224,8 +302,14 @@ export async function initKeySetup({
const statusLine = root.querySelector('[data-key-setup-status]');
const defaultStatusText = statusLine?.textContent || '';
let busy = false;
let oauthBusy = false;
let open = false;
const syncCloudVoiceAuth = () => {
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
updateCloudVoiceAuthControl(auth, oauthBusy);
};
const render = (nextStatus) => {
if (disposed) return;
status = nextStatus;
@@ -239,6 +323,7 @@ export async function initKeySetup({
rowsHost.textContent = '';
for (const key of status.keys || [])
rowsHost.append(buildRow(documentRef, key));
syncCloudVoiceAuth();
};
const visible = () =>
@@ -367,6 +452,100 @@ export async function initKeySetup({
applyButton?.addEventListener('click', onApply);
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const authButton = event.target?.closest?.(
'[data-cloud-voice-auth-toggle]',
);
if (authButton && !disposed) {
if (oauthBusy) return;
void (async () => {
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncCloudVoiceAuth();
say('Cloud voice will use OPENAI_API_KEY on the next session.');
return;
}
oauthBusy = true;
syncCloudVoiceAuth();
say('Checking local ChatGPT OAuth sign-in…');
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw new Error(
payload.error || 'Could not check ChatGPT sign-in.',
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? 'ChatGPT sign-in expired. Opening sign-in again…'
: 'Opening ChatGPT sign-in in your browser…',
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok) {
say(
loginPayload.error ||
payload.error ||
'Could not start ChatGPT sign-in on this machine.',
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say(
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(`OAuth check failed: ${error?.message || error}`);
} finally {
oauthBusy = false;
if (!disposed) syncCloudVoiceAuth();
}
})();
return;
}
const button = event.target?.closest?.('[data-key-setup-remove]');
if (disposed || !button || busy) return;
let envVars = [];
+59
View File
@@ -5,6 +5,7 @@ import {
keySetupChipLabel,
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
waitForChatGptOAuth,
} from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
@@ -61,6 +62,64 @@ test('collectKeyUpdates keeps only non-empty trimmed values', () => {
assert.deepEqual(collectKeyUpdates(null), {});
});
test('OAuth login polling stops as soon as local ChatGPT auth becomes available', async () => {
let checks = 0;
let clock = 0;
const available = await waitForChatGptOAuth({
fetchImpl: async () => {
checks += 1;
return {
ok: true,
json: async () => ({ available: checks >= 3 }),
};
},
timeoutMs: 10_000,
pollMs: 100,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
});
assert.equal(available, true);
assert.equal(checks, 3);
});
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
{ ok: false, payload: { error: 'Sign-in is local only.' } },
]) {
let checks = 0;
await assert.rejects(waitForChatGptOAuth({
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
sleep: () => assert.fail('failed login must stop polling'),
}), { message: result.payload.error });
assert.equal(checks, 1);
}
});
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0;
let checks = 0;
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
assert.equal(checks, 2);
const controller = new AbortController();
controller.abort();
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
assert.equal(checks, 2, 'aborted polling does not fetch');
});
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
const controller = new AbortController();
const available = await waitForChatGptOAuth({
signal: controller.signal,
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
sleep: () => assert.fail('aborted polling must not continue'),
});
assert.equal(available, false);
});
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
assert.ok(stripped !== null);
+12 -2
View File
@@ -28,7 +28,7 @@ export const KEY_SETUP_APPEND_HEADER =
* Provider credentials, in display order — most magic per
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
* `clientExposed` marks the two keys that are injected into the browser
* `clientExposed` marks the keys that are injected into the browser
* bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count.
*/
@@ -55,7 +55,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'openai',
title: 'OPENAI',
unlocks: 'Voice control — talk to the planet',
unlocks: 'Voice control — API key or ChatGPT OAuth',
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
@@ -101,6 +101,16 @@ export const KEY_SETUP_KEYS = Object.freeze([
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free',
}),
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY',
unlocks:
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
clientExposed: true,
}),
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY',
+31
View File
@@ -392,6 +392,37 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
};
},
/**
* Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!state.enabled) return [];
const result = [];
for (const record of state.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.id,
lat: record.latitude,
lon: record.longitude,
operator: record.operator ?? null,
manufacturer: record.manufacturer ?? null,
cameraType: record.cameraType ?? null,
directionDeg: Number.isFinite(record.directionDeg)
? record.directionDeg
: null,
});
}
return result;
},
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
+50
View File
@@ -75,6 +75,56 @@ export function createControls({ state: layerState, services, parts, source }) {
);
},
/**
* Return the exact retained aircraft cohorts behind the current Contacts
* panel snapshot. Voice reads this immutable copy instead of re-scanning
* live layers, so panel and spoken counts share one evaluation timestamp.
*/
getAircraftQuerySnapshot() {
if (!layerState.enabled || !layerState.subject || !layerState.results)
return null;
// Build every public count from this exact results object before
// returning. A refresh may replace `layerState.results` as soon as the
// caller awaits, so consumers must not make a second read later and
// accidentally combine two evaluation timestamps.
const panelSnapshot = parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
const cohorts = {};
for (const cohort of layerState.results.cohorts || []) {
if (!['flights', 'military'].includes(cohort?.id)) continue;
cohorts[cohort.id] = {
count: cohort.summary?.count ?? null,
complete:
Number.isFinite(cohort.summary?.count) &&
cohort.summary?.complete !== false,
truncated:
!Number.isFinite(cohort.summary?.count) ||
cohort.summary?.truncated === true,
// These records carry structured values such as Cartesian positions.
// A shallow spread would let a voice consumer mutate the retained
// Contacts cohort through the advertised read-only snapshot.
items: structuredClone(cohort.summary?.navigationNearest || []),
source: cohort.source || null,
provenance: cohort.provenance
? structuredClone(cohort.provenance)
: null,
reason: cohort.summary?.reason || null,
};
}
return {
subject: structuredClone(layerState.results.subject),
evaluatedAt: layerState.results.evaluatedAt,
radiusM: layerState.results.radiusM,
cohorts,
contactsWindow: parts.model.contactsWindowFromSnapshot(panelSnapshot),
};
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
+11
View File
@@ -144,6 +144,9 @@ export function createModel({ state: layerState, services, parts, source }) {
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
['flights', 'military'].includes(cohort?.id),
);
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
flights,
military,
vessels: countFor('ais-live-vessels'),
complete:
aircraftCohorts.length === 2 &&
aircraftCohorts.every(
(cohort) =>
Number.isFinite(cohort.count) && cohort.complete !== false,
),
};
}
@@ -190,6 +199,8 @@ export function createModel({ state: layerState, services, parts, source }) {
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
complete: cohort.summary.complete !== false,
truncated: cohort.summary.truncated === true,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
+3
View File
@@ -25,6 +25,7 @@ export function createNavigation({
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
complete = true,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
@@ -34,6 +35,8 @@ export function createNavigation({
if (summary.count === null) return summary;
return {
...summary,
complete: Boolean(complete),
truncated: !complete,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
+10 -1
View File
@@ -5,6 +5,15 @@ import {
} from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = summary.count.toLocaleString('en-US');
return summary.complete === false || summary.truncated === true
? `At least ${count}`
: count;
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -61,7 +70,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const count = formatAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
+2 -2
View File
@@ -38,8 +38,8 @@ export const AWARENESS_MAX_EXAMPLES = 10;
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
// refresh. Callers sample beyond this cap and carry explicit completeness, so
// a saturated cohort is a lower bound rather than a silently exact count.
export const AWARENESS_QUERY_LIMIT = 20000;
+29 -8
View File
@@ -53,7 +53,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
return {
available: !unavailable,
enabled,
stale: Boolean(stats.stale),
stats,
};
}
function collectSourceStates() {
@@ -147,8 +152,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
* @returns {{flights: Array, military: Array, aircraft: number,
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
* their lower-bound completeness, and the combined aircraft count, or
* null without a position.
*/
function collectAircraftProximityWindow(
@@ -156,21 +163,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
// Ask for one usable row beyond the retained limit so a capped cohort is
// reported as a lower bound instead of an exact count. The selected
// subject can consume one slot before exclusion, hence the extra two.
const sampleLimit = AWARENESS_QUERY_LIMIT + 2;
const flightSample = flightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
const militarySample = militaryFlightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT);
const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT);
return {
flights,
military,
aircraft: flights.length + military.length,
completeByLayer: {
flights: flightSample.length <= AWARENESS_QUERY_LIMIT,
military: militarySample.length <= AWARENESS_QUERY_LIMIT,
},
};
}
return {
sourceState,
+33
View File
@@ -6,6 +6,39 @@ import { createSubject } from './subject.js';
import { createControls } from '../installations/controls.js';
import { createModel } from '../installations/model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_QUERY_LIMIT } from './policy.js';
test('aircraft window marks a capped layer as a lower bound', () => {
const rows = Array.from({ length: AWARENESS_QUERY_LIMIT + 1 }, (_, index) => ({
icao24: `f${index}`,
distance: index,
}));
let requested = null;
const services = {
flights: {
getNearby: (_position, _radius, limit) => {
requested = limit;
return rows.slice(0, limit);
},
},
military: { getNearby: () => [] },
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
},
};
const queries = createQueries({ state: {}, services, parts });
const window = queries.collectAircraftProximityWindow({ center: true });
assert.equal(requested, AWARENESS_QUERY_LIMIT + 2);
assert.equal(window.flights.length, AWARENESS_QUERY_LIMIT);
assert.equal(window.aircraft, AWARENESS_QUERY_LIMIT);
assert.deepEqual(window.completeByLayer, {
flights: false,
military: true,
});
});
test('Contacts excludes square corners and measures from the current moving subject', () => {
const records = [
+46 -2
View File
@@ -37,11 +37,23 @@ export function createSubject({ state: layerState, services, parts, source }) {
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
id,
name: label,
enabled: sourceState.enabled,
source: retainedSource,
stats: { ...sourceState.stats, source: retainedSource },
};
};
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const { flights, military, completeByLayer } = aircraftWindow;
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
@@ -61,31 +73,57 @@ export function createSubject({ state: layerState, services, parts, source }) {
);
return {
subject,
evaluatedAt: Date.now(),
evaluatedAt,
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
'Flights',
flightsState,
SOURCE_LABEL.flights,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.flights,
},
),
},
{
id: 'military',
label: 'Military flights',
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
'Military flights',
militaryState,
SOURCE_LABEL.military,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.military,
},
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
vesselsState,
SOURCE_LABEL['ais-live-vessels'],
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
@@ -97,6 +135,12 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
installationsState,
SOURCE_LABEL['military-installations'],
),
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
+33
View File
@@ -21,6 +21,39 @@ export function createControls({ state: layerState, services, parts, source }) {
return parts.queries.collectDetectableStations(options);
},
/**
* Snapshot loaded bikeshare stations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, city, lat, lon, bikesAvailable, docksAvailable, capacity, renting}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const record of layerState._stationRenderMap.values()) {
if (result.length >= maxCount) break;
const info = layerState._stationInfoCache
.get(record.cityId)
?.get(record.stationId);
if (!Number.isFinite(info?.lat) || !Number.isFinite(info?.lon))
continue;
result.push({
id: record.stationName || `Dock ${record.stationId}`,
name: record.stationName || null,
city: record.cityId || null,
lat: info.lat,
lon: info.lon,
bikesAvailable: num(record.bikesAvailable),
docksAvailable: num(record.docksAvailable),
capacity: num(record.capacity),
renting: record.isRenting !== false,
});
}
return result;
},
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
+29
View File
@@ -403,6 +403,35 @@ export function createCyclonesLayer({
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
/**
* Snapshot loaded active storms as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!enabled || destroyed) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const storm of snapshot?.storms || []) {
if (result.length >= maxCount) break;
const lat = storm.position?.latitude;
const lon = storm.position?.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
result.push({
id: storm.name || storm.id,
name: storm.name || null,
classification: storm.classification || null,
basin: storm.basin || null,
lat,
lon,
windKt: num(storm.windKt),
pressureHpa: num(storm.pressureHpa),
});
}
return result;
},
getStats() {
const storm = selected();
return {
+11 -18
View File
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
* @param {{lat:number, lon:number}} b
* @returns {string}
*/
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
export { directionsRequestUrl } from './source.js';
/**
* Validate a proxy payload into the route record the layer keeps, or null.
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
* route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter.
* @param {{services: object}} options
* @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers.
*/
export function createDirectionsLayer({ services }) {
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
@@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) {
_error = null;
notifyRow();
try {
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
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) {
+34
View File
@@ -0,0 +1,34 @@
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** Acquire route records using caller-owned transport and an optional endpoint. */
export function createDirectionsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/route',
} = {}) {
return {
async getRoute({ mode, a, b }, { signal } = {}) {
signal?.throwIfAborted();
const query = directionsRequestUrl(mode, a, b).split('?')[1];
const response = await fetchImpl(`${endpoint}?${query}`, {
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
signal?.throwIfAborted();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
return payload;
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDirectionsSource } from './source.js';
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
test('route acquisition uses only the supplied transport and endpoint', async () => {
const controller = new AbortController();
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
const calls = [];
const source = createDirectionsSource({
endpoint: 'https://routes.example/route',
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
});
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
assert.equal(calls.length, 1);
const [url, init] = calls[0];
assert.equal(new URL(url).origin, 'https://routes.example');
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
assert.equal(init.signal, controller.signal);
assert.equal(init.headers.Accept, 'application/json');
});
test('aborting during route parsing discards the result', async () => {
const controller = new AbortController();
const source = createDirectionsSource({ fetchImpl: async () => ({
ok: true, json: async () => { controller.abort(); return { ok: true }; },
}) });
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
});
test('route failures are surfaced without fallback requests', async () => {
for (const status of [429, 503]) {
let calls = 0;
const source = createDirectionsSource({ fetchImpl: async () => {
calls++;
return Response.json({}, { status });
} });
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
assert.equal(calls, 1);
}
});
+3 -1
View File
@@ -29,7 +29,8 @@ export function createModel({
* context-store ids, so the two stay consistent).
* @param {Object|null|undefined} fire - Internal fire record.
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
* confidence: number|null, satellite: string|null, acqTime: number|null}}
* confidence: number|null, satellite: string|null, sensor: string|null,
* acqTime: number|null}}
*/
function mapAnalystRecord(fire) {
@@ -45,6 +46,7 @@ export function createModel({
frp: num(fire?.frp),
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
satellite: text(fire?.satellite) || text(fire?.sensor),
sensor: text(fire?.sensor), // VIIRS or MODIS
acqTime:
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
};
+17 -1
View File
@@ -179,8 +179,24 @@ export function createFlightSnapshotRenderer({
// as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time, and a grounded contact is then
// lifted onto the floor under that delayed position. Creating it at
// the raw fix drew it ahead until the next fleet tick snapped it back;
// skipping the floor started a taxiing contact low and then raised it.
const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3());
const displayed =
(delayed &&
motion._floorGroundedDisplayPosition(
icao24,
flightState.records.data.get(icao24) || meta,
delayed,
rendering._modelOwnsVisual(icao24),
)) ||
position;
const bb = flightState._billboardCollection.add({
position,
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
+29
View File
@@ -162,6 +162,35 @@ export function createControls({ state: layerState, services, parts, source }) {
return true;
},
/**
* Snapshot loaded mapped installations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, class, kind, lat, lon}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState.enabled) return [];
const result = [];
for (const record of layerState.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.name || record.id,
name: record.name || null,
class: record.class || null,
kind: record.kind || null,
lat: record.latitude,
lon: record.longitude,
});
}
return result;
},
getStats() {
const namedMarkers = parts.namedMarkers?.stats();
const subjectWindow =
+34
View File
@@ -17,6 +17,40 @@ export function createControls({ state: layerState, services, parts, source }) {
parts.selection.stopMissionZoomAnchor();
},
/**
* Snapshot loaded launches as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, provider, status, launchSite, missionName, lat, lon, launchTimeMs, hoursUntil}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const now = Date.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = [];
for (const launch of layerState._launches || []) {
if (result.length >= maxCount) break;
const at = Date.parse(launch.launchTime);
result.push({
id: launch.id,
name: text(launch.name),
provider: text(launch.provider),
status: text(launch.status),
launchSite: text(launch.launchSite),
missionName: text(launch.missionName),
lat: launch.lat,
lon: launch.lon,
launchTimeMs: Number.isFinite(at) ? at : null,
// Negative for launches already flown.
hoursUntil: Number.isFinite(at)
? Math.round(((at - now) / 3_600_000) * 10) / 10
: null,
});
}
return result;
},
getStats() {
return {
count: layerState._count,
+34
View File
@@ -828,6 +828,40 @@ export function createLocalAdsbLayer({
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
},
/**
* Snapshot loaded locally received aircraft as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, icao24, callsign, lat, lon, altitudeM, speedKts, heading, onGround}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!enabled) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const marker of markers.values()) {
if (result.length >= maxCount) break;
const record = marker.record || {};
const lat = marker.lat ?? record.lat;
const lon = marker.lon ?? record.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
result.push({
id: record.callsign || record.icao,
icao24: record.icao || null,
callsign: record.callsign || null,
lat,
lon,
altitudeM: Number.isFinite(record.altitudeFt)
? Math.round(record.altitudeFt * 0.3048)
: null,
speedKts: num(record.groundSpeedKt),
heading: num(record.trackDeg),
onGround: record.onGround === true,
});
}
return result;
},
getAllPositions(maxCount = 500) {
return positionRows(maxCount);
},
+1
View File
@@ -33,6 +33,7 @@ export function createMilitaryFlightLayer({
militaryRegistry: services.militaryRegistry,
rendering: parts.rendering,
tracking: parts.tracking,
motion: parts.motion,
queries: parts.queries,
});
parts.ingestion = createIngestion({
+98
View File
@@ -113,3 +113,101 @@ test('a normalized source can retain its stale reason without changing standalon
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().stale, true);
});
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
// contact's first displayed position is its fix projected back to that
// delayed time. Creating the billboard at the raw fix drew it ahead until
// the next fleet tick jumped it back.
const Cesium = await import('cesium');
const { RENDER_DELAY_SEC } = await import('./policy.js');
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
const supplied = {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
const fixLon = -97.6;
const fixLat = 30.3;
const speedMps = 200;
const fixAgeMs = 2_000;
const nowMs = Date.now();
const layer = createMilitaryFlightLayer({
services: supplied,
source: {
label: 'Fixture aircraft',
async getSnapshot() {
return {
source: 'Fixture aircraft',
complete: true,
observedAtMs: nowMs,
records: [
{
id: 'ae0001',
latitude: fixLat,
longitude: fixLon,
onGround: false,
baroAltitudeM: 9_000,
courseDeg: 90,
speedMps,
positionTimeMs: nowMs - fixAgeMs,
contactTimeMs: nowMs - fixAgeMs,
},
],
};
},
},
});
const added = [];
layer.testing._setTrackedMilitaryRefreshStateForTest({
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
billboard: {
show: false,
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
billboardCollection: {
show: false,
add(options) {
const billboard = { ...options };
added.push(billboard);
return billboard;
},
remove() {},
},
viewer: { camera: { positionCartographic: null }, scene: {} },
tracked: false,
});
await layer.update({ camera: { positionCartographic: null }, scene: {} });
const billboard = added.find((entry) => entry.id === 'ae0001');
assert.ok(billboard, 'the new contact gets a billboard');
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
const behindM = Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
);
const expectedM = speedMps * (RENDER_DELAY_SEC - fixAgeMs / 1000);
assert.ok(
Cesium.Math.toDegrees(shown.longitude) < fixLon,
'drawn behind the fix',
);
assert.ok(
Math.abs(behindM - expectedM) < speedMps * 3,
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
+8 -1
View File
@@ -23,6 +23,7 @@ export function createMilitarySnapshotRenderer({
militaryRegistry,
rendering,
tracking,
motion,
queries,
}) {
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
@@ -142,8 +143,14 @@ export function createMilitarySnapshotRenderer({
// Round 5: depth policy is uniform (always depth-test-free, see
// _groundDepthDistance) — nothing to flip on landing/takeoff.
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time. Creating it at the raw fix drew
// it ahead until the next fleet tick snapped it back.
const displayed =
motion._deadReckon(icao24, new Cesium.Cartesian3()) || position;
const bb = flightState._billboardCollection.add({
position,
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
+4 -2
View File
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
*/
export function createRecentImageryRenderer({
cesium = Cesium,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
url: tileTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
box.east,
box.north,
),
credit: 'NASA GIBS',
credit,
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
+20 -1
View File
@@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
...options,
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
const { renderer, globe } = fixture({
tileTemplate: (product, day) =>
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.equal(renderer.showSlot('a', S30, BOX), true);
const layer = globe.layers[0];
assert.equal(
layer.provider.options.url,
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
);
assert.equal(layer.provider.options.credit, 'Example imagery');
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(layer.destroyed, true);
});
+32
View File
@@ -0,0 +1,32 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
export function createRecentImagerySource({
fetchImpl = (...args) => globalThis.fetch(...args),
search = searchHls,
thumbnailUrl = wvsSnapshotUrl,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
} = {}) {
return {
credit,
searchHls: (query) => search({ ...query, fetchImpl }),
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
thumbnailUrl({
product: candidate.product,
day: candidate.day,
box,
width: size,
height: size,
}),
{ signal },
);
signal?.throwIfAborted();
return response;
},
getTileTemplate: (product, day) => tileTemplate(product, day),
};
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRecentImagerySource } from './source.js';
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
const controller = new AbortController();
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
const candidate = { product: 'S30', day: '2026-10-01' };
const calls = [];
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
const source = createRecentImagerySource({
fetchImpl,
search: async (request) => {
assert.equal(request.fetchImpl, fetchImpl);
assert.equal(request.signal, controller.signal);
assert.equal(request.box, query.box);
return [candidate];
},
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.deepEqual(await source.searchHls(query), [candidate]);
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
assert.equal(calls[0][1].signal, controller.signal);
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
assert.equal(source.credit, 'Example imagery');
});
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
const controller = new AbortController();
let calls = 0;
const source = createRecentImagerySource({
thumbnailUrl: () => 'https://imagery.example/thumbnail',
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
});
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 1);
});
+6 -8
View File
@@ -8,7 +8,8 @@
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
import { thumbnailOrder } from './model.js';
import { createRecentImagerySource } from './source.js';
const EMPTY = Object.freeze({
status: 'unknown',
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
const response = await requestThumbnail(entry.candidate, entry.box, {
size,
signal: controller.signal,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
tracked: 1,
});
});
test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => {
const calls = [];
const { loader, fetch, revoked } = fixture({
requestThumbnail: async (...args) => {
calls.push(args);
return response({ headers: { 'Data-Present': 'true' } });
},
});
const entry = candidate('S30', '2026-09-18');
loader.request(entry, BOX, 0);
await settle();
assert.equal(fetch.calls.length, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], entry);
assert.deepEqual(calls[0][1], BOX);
assert.ok(calls[0][2].signal instanceof AbortSignal);
assert.equal(loader.get(entry.key).status, 'present');
loader.destroy();
assert.deepEqual(revoked, ['blob:1']);
});
+209
View File
@@ -0,0 +1,209 @@
import * as Cesium from 'cesium';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000;
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('street-level-follow');
}
/**
* The application's camera authority (`attachNavigation`): `run(noun, move)`
* releases tracking and other owners before `move`, or refuses (cockpit);
* `begin(noun)` takes a deferred ticket (a generation, or false) that
* `reassert(generation)` honours only if nothing newer took the camera;
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
*/
let navigation = null;
let unsubscribeHandoff = null;
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
let claiming = false;
/** Take the camera for `move`; false when the application refuses. */
function claim(move) {
if (!navigation?.run) {
move();
return true;
}
claiming = true;
try {
return (
navigation.run('photo', () => {
move();
return true;
}) === true
);
} finally {
claiming = false;
}
}
/**
* Ask for the camera as a photo starts opening. The ticket goes to
* `lookAtPosition`; null when the application refuses (cockpit).
*/
function beginFraming() {
if (!navigation?.begin || !navigation.reassert) return { generation: null };
claiming = true;
try {
const generation = navigation.begin('photo');
return generation === false ? null : { generation };
} finally {
claiming = false;
}
}
/** Another feature took the camera: stop following it. */
function onHandoff() {
if (claiming || !state.street.follow) return;
state.street.follow = false;
state.notify?.();
}
function attachNavigation(next) {
unsubscribeHandoff?.();
navigation = next || null;
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
* within the mesh window around bare earth (once that is known).
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const caster = parts?.groundCaster;
const dem = caster?.groundAt(lon, lat) ?? null;
if (dem === null) caster?.prepare([[lon, lat]]);
// The application's mesh window around bare earth, once that is known.
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
const plausible = (height) =>
Number.isFinite(height) &&
height >= SURFACE_MIN_M &&
height <= SURFACE_MAX_M &&
(dem === null || !withinPrior || withinPrior(height, dem));
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
if (!plausible(height)) height = dem;
if (!plausible(height)) height = scene?.globe?.getHeight?.(carto);
if (!plausible(height)) height = fallback;
return plausible(height) ? height : 0;
}
/** Put the Cesium camera where the street-level camera is. */
function followCamera() {
const { position, bearing, tilt, follow, altitude } = state.street;
if (!follow || !position || !state.viewer) return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
state.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
ground + FOLLOW_EYE_HEIGHT_M,
),
orientation: {
heading: Cesium.Math.toRadians(bearing || 0),
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
roll: 0,
},
});
requestRender();
}
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let framing = null;
/**
* Frame the current image from a short distance behind it, unless another
* action took the camera since `ticket` (from `beginFraming`) was issued.
*/
function lookAtPosition(ticket) {
const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return;
if (
ticket.generation !== null &&
!navigation?.reassert?.(ticket.generation)
)
return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
const fly = () => {
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const flight = {};
framing = flight;
const release = () => {
if (framing === flight) framing = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
};
// A reasserted ticket has already released the other owners.
if (ticket.generation === null) claim(fly);
else fly();
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancelFraming() {
if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
framing = null;
state.viewer?.camera?.cancelFlight?.();
}
function setFollow(enabled) {
const wanted = enabled === true && state.street.followAvailable;
if (wanted && !state.street.follow) {
// Following owns the camera until another feature takes it.
const granted = claim(() => {
state.street.follow = true;
// The framing tween would keep overwriting the follow view until it lands.
cancelFraming();
followCamera();
});
if (!granted) state.street.follow = false;
} else if (!wanted) {
state.street.follow = false;
}
state.notify?.();
}
function destroy() {
attachNavigation(null);
}
return {
followCamera,
beginFraming,
lookAtPosition,
cancelFraming,
setFollow,
attachNavigation,
destroy,
};
}
@@ -0,0 +1,265 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFollow } from './cameraFollow.js';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights, views) {
let current = null;
function stop() {
const flight = current;
current = null;
flight?.options.cancel?.();
return Boolean(flight);
}
const camera = {
cancelled: 0,
flyToBoundingSphere(sphere, options = {}) {
stop();
current = { sphere, options };
flights.push(current);
},
cancelFlight() {
if (stop()) camera.cancelled++;
},
land() {
const flight = current;
current = null;
flight?.options.complete?.();
},
setView: (view) => views.push(view),
};
return camera;
}
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose bare earth is `dem`.
*/
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = [];
const views = [];
const state = {
services: {},
street: {
position: { lon: -97.7364, lat: 30.2672 },
bearing: 105,
tilt: -10,
altitude,
follow: false,
followAvailable: true,
},
viewer: {
scene: {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
},
camera: flightCamera(flights, views),
},
};
const parts = {
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
};
return {
follow: createCameraFollow({ state, parts }),
state,
flights,
views,
};
}
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.equal(flights.length, 1);
const centre = heightOf(flights[0].sphere.center);
assert.ok(
Math.abs(centre - 119) < 0.01,
`centre at ${centre} m, not underground`,
);
});
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
const { follow, flights } = setup({ sampled: 121, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
});
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
const { follow, flights } = setup({
sampled: -14_886,
dem: null,
globe: undefined,
altitude: 149,
});
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
});
test('following stands the camera at eye height above the checked ground', () => {
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
state.street.follow = true;
follow.followCamera();
assert.equal(views.length, 1);
assert.ok(
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
0.01,
);
});
test('cancelFraming stops the framing flight while it is still ours', () => {
const { follow, state } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
follow.lookAtPosition(follow.beginFraming());
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 2);
});
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
camera.land();
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
follow.lookAtPosition(follow.beginFraming());
// A search result flies the globe elsewhere; Cesium cancels ours first.
camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
});
test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => {
const { follow, state, views } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(camera.cancelled, 1, 'the framing tween stops');
assert.equal(views.length, 1, 'and the camera stands at the photo');
});
/**
* The application's camera authority: `run` releases tracking (here, a tracked
* entity) and stamps a handoff before the move, or refuses like the cockpit.
*/
function navigationFor(state, { refuse = false } = {}) {
const listeners = new Set();
let generation = 0;
const stamp = () => {
generation++;
for (const listener of listeners) listener({ generation });
return generation;
};
const nav = {
runs: [],
begins: [],
run(noun, move) {
nav.runs.push(noun);
if (refuse) return false;
const current = stamp();
state.viewer.trackedEntity = undefined;
return move(current);
},
/** Deferred: stamp now, release only on a successful reassert. */
begin(noun) {
nav.begins.push(noun);
return refuse ? false : stamp();
},
reassert(ticket) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
return true;
},
subscribeHandoff(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Another feature takes the camera. */
handOff: () => stamp(),
listeners,
};
return nav;
}
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { follow, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
follow.lookAtPosition(ticket);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a photo that finishes loading after newer navigation does not frame', () => {
const { follow, state, flights } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
follow.lookAtPosition(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
});
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
const { follow, state, flights, views } = setup({ sampled: 160 });
follow.attachNavigation(navigationFor(state, { refuse: true }));
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(flights.length, 0);
assert.equal(views.length, 0);
assert.equal(state.street.follow, false);
});
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
const { follow, state, views } = setup({ sampled: 160 });
let notified = 0;
state.notify = () => notified++;
const nav = navigationFor(state);
follow.attachNavigation(nav);
follow.setFollow(true);
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
assert.deepEqual(nav.runs, ['photo']);
follow.followCamera();
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
assert.equal(views.length, 2);
nav.handOff();
assert.equal(state.street.follow, false);
assert.ok(notified >= 2);
follow.followCamera();
assert.equal(views.length, 2, 'the camera is no longer driven');
});
test('destroying the follow part stops listening for handoffs', () => {
const { follow, state } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
assert.equal(nav.listeners.size, 1);
follow.destroy();
assert.equal(nav.listeners.size, 0);
});
+38
View File
@@ -0,0 +1,38 @@
import * as Cesium from 'cesium';
/**
* One static Cesium credit per active provider: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
const shown = new Map();
function show(viewer, def) {
if (shown.has(def.id) || !viewer?.creditDisplay) return;
try {
const credit = new Cesium.Credit(def.credit.html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
} catch {
/* credit display unavailable */
}
}
function hide(viewer, def) {
const credit = shown.get(def.id);
if (!credit) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(credit);
} catch {
/* credit display already torn down */
}
shown.delete(def.id);
}
function hideAll(viewer) {
for (const id of [...shown.keys()]) hide(viewer, { id });
}
return { show, hide, hideAll };
}
+58
View File
@@ -0,0 +1,58 @@
import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
const DAY_MS = 86_400_000;
/**
* Merge a partial filter change; unknown pano modes and invalid day counts
* keep the current value.
* @param {{pano?: string, sinceDays?: number}|null} next
* @param {{pano: string, sinceDays: number}} [current]
* @returns {{pano: string, sinceDays: number}}
*/
export function normalizeFilter(next, current = FILTER_DEFAULT) {
const pano = PANO_MODES.includes(next?.pano) ? next.pano : current.pano;
let sinceDays = current.sinceDays;
if (next && 'sinceDays' in next) {
const days = Number(next.sinceDays);
if (Number.isInteger(days) && days >= 0)
sinceDays = Math.min(days, MAX_SINCE_DAYS);
}
return { pano, sinceDays };
}
/** True when two filters would show the same imagery. */
export function sameFilter(a, b) {
return a?.pano === b?.pano && a?.sinceDays === b?.sinceDays;
}
/**
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
const days = Number(filter?.sinceDays) || 0;
return {
pano: PANO_MODES.includes(filter?.pano) ? filter.pano : 'all',
sinceMs: days > 0 ? now - days * DAY_MS : null,
};
}
/**
* Whether a capture passes the resolved filter: pano mode and an optional
* earliest capture time.
* @param {{isPano?: boolean, capturedAt?: number}} record
* @param {{pano: string, sinceMs: number|null}|null} filter
*/
export function passesImageryFilter(record, filter) {
if (!filter) return true;
if (filter.pano === 'pano' && !record.isPano) return false;
if (filter.pano === 'flat' && record.isPano) return false;
if (
Number.isFinite(filter.sinceMs) &&
filter.sinceMs > 0 &&
(record.capturedAt || 0) < filter.sinceMs
)
return false;
return true;
}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeFilter,
passesImageryFilter,
resolveFilter,
sameFilter,
} from './filter.js';
import { MAX_SINCE_DAYS } from './policy.js';
const DAY = 86_400_000;
test('normalizeFilter merges a partial change and ignores junk', () => {
const current = { pano: 'all', sinceDays: 0 };
assert.deepEqual(normalizeFilter({ pano: 'pano' }, current), {
pano: 'pano',
sinceDays: 0,
});
assert.deepEqual(normalizeFilter({ pano: 'sideways' }, current), current);
assert.deepEqual(normalizeFilter({ sinceDays: 365 }, current), {
pano: 'all',
sinceDays: 365,
});
assert.deepEqual(normalizeFilter({ sinceDays: -3 }, current), current);
assert.deepEqual(normalizeFilter({ sinceDays: 1.5 }, current), current);
assert.deepEqual(normalizeFilter({ sinceDays: 99_999_999 }, current), {
pano: 'all',
sinceDays: 36_500,
});
assert.deepEqual(normalizeFilter(null, current), current);
assert.deepEqual(normalizeFilter({ pano: 'flat' }), {
pano: 'flat',
sinceDays: 0,
});
});
test('resolveFilter turns relative days into an absolute cut-off', () => {
const now = Date.UTC(2026, 8, 25);
assert.deepEqual(resolveFilter({ pano: 'all', sinceDays: 0 }, now), {
pano: 'all',
sinceMs: null,
});
assert.deepEqual(resolveFilter({ pano: 'flat', sinceDays: 10 }, now), {
pano: 'flat',
sinceMs: now - 10 * DAY,
});
assert.equal(resolveFilter({ pano: 'nope' }, now).pano, 'all');
});
test('passesImageryFilter applies panorama mode and the cut-off', () => {
const pano = { isPano: true, capturedAt: 200 };
const flat = { isPano: false, capturedAt: 50 };
assert.equal(passesImageryFilter(pano, null), true);
assert.equal(
passesImageryFilter(pano, { pano: 'pano', sinceMs: null }),
true,
);
assert.equal(
passesImageryFilter(flat, { pano: 'pano', sinceMs: null }),
false,
);
assert.equal(
passesImageryFilter(flat, { pano: 'flat', sinceMs: null }),
true,
);
assert.equal(
passesImageryFilter(pano, { pano: 'flat', sinceMs: null }),
false,
);
assert.equal(passesImageryFilter(flat, { pano: 'all', sinceMs: 100 }), false);
assert.equal(passesImageryFilter(pano, { pano: 'all', sinceMs: 100 }), true);
assert.equal(passesImageryFilter({}, { pano: 'all', sinceMs: 1 }), false);
});
test('sameFilter compares the stored form', () => {
assert.equal(
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 0 }),
true,
);
assert.equal(
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 1 }),
false,
);
});
test('normalizeFilter takes "any date" (0 days) over a current window (share-link defaults)', () => {
// Dragging SINCE back to ANY DATE, or a link that says so, must stick.
assert.equal(
normalizeFilter({ sinceDays: 0 }, { pano: 'all', sinceDays: 365 })
.sinceDays,
0,
);
assert.equal(
normalizeFilter(
{ sinceDays: MAX_SINCE_DAYS },
{ pano: 'all', sinceDays: 0 },
).sinceDays,
MAX_SINCE_DAYS,
'the longest window is kept whole',
);
});
+75
View File
@@ -0,0 +1,75 @@
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
const _cache = new Map();
/** A square canvas glyph drawn once per key, centred at (c, c). */
function cachedGlyph(key, size, draw) {
if (_cache.has(key)) return _cache.get(key);
const element = document.createElement('canvas');
element.width = size;
element.height = size;
draw(element.getContext('2d'), size / 2);
_cache.set(key, element);
return element;
}
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.fill();
ctx.globalAlpha = 1;
}
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.globalAlpha = alpha;
ctx.stroke();
ctx.globalAlpha = 1;
}
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = width;
ctx.strokeStyle = outline;
ctx.stroke();
}
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
export function imageConeGlyph({
size = 32,
color = '#e8eaed',
pano = false,
} = {}) {
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
dot(
ctx,
c,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
);
});
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
});
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Ground casting for street-level overlays on Google 3D. Draped overlays land
* on the mesh top (trees, overpasses), so casting places them at bare-earth
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* Heights come from a coarse grid shared with the ground-floor service; a
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
*/
import { wrapLon } from './tileMath.js';
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
export const GROUND_CAST_STEP_DEG = 0.001;
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
/** Metres above the bare earth, so lines sit on the road instead of in it. */
export const GROUND_CAST_LIFT_M = 2;
/** Grid corners one prepare call may request; more stays draped. */
export const GROUND_CAST_MAX_CORNERS = 1024;
/** Cached grid corners before the oldest are dropped to make room. */
const GROUND_CAST_CACHE_MAX = 50_000;
/**
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* misses trenches, steep streets and roads under trees):
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - higher mesh is over the road (canopy, deck): interpolate the road's
* offset from the nearest road samples on both sides;
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
*/
export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */
export const MESH_ROAD_BELOW_M = 30;
/** Metres above the sampled road surface. */
export const MESH_LIFT_M = 1;
/** Longest run of covered points bridged from the road on either side. */
export const MESH_BRIDGE_MAX_POINTS = 8;
/** Line spacing, in degrees (~22 m), while the mesh refines heights. */
export const MESH_DENSIFY_DEG = 0.0002;
/**
* Heights for a line's points by the rule above.
* @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options]
* @returns {Array<number>}
*/
export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) {
const road = points.map(({ dem, mesh }) =>
Number.isFinite(mesh) &&
mesh <= dem + MESH_ROAD_ABOVE_M &&
mesh >= dem - MESH_ROAD_BELOW_M
? mesh - dem
: null,
);
return points.map(({ dem, mesh }, i) => {
if (road[i] !== null) return dem + road[i] + MESH_LIFT_M;
if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) {
let a = i - 1;
while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--;
let b = i + 1;
while (
b < points.length &&
b - i <= MESH_BRIDGE_MAX_POINTS &&
road[b] === null
)
b++;
const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null;
const right =
b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null;
if (left !== null && right !== null) {
const t = (i - a) / (b - a);
return dem + left + (right - left) * t + MESH_LIFT_M;
}
}
return dem + lift;
});
}
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
export const SURFACE_TERRAIN_ENTER_M = 1400;
/** Camera height above which terrain mode switches back to draped. */
export const SURFACE_TERRAIN_EXIT_M = 1800;
/**
* Next surface mode: terrain only on Google 3D at street zoom, with
* hysteresis between the enter and exit heights.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
*/
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
if (current === 'terrain')
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
}
/**
* Split long segments so a line follows the terrain. Segments across ±180°
* go the short way round, with added points wrapped into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
*/
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
const out = [coords[0]];
for (let i = 1; i < coords.length; i++) {
const [lon0, lat0] = coords[i - 1];
const [lon1, lat1] = coords[i];
const dLon = wrapLon(lon1 - lon0);
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
for (let k = 1; k < parts; k++) {
const t = k / parts;
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
}
out.push(coords[i]);
}
return out;
}
/**
* A bare-earth height cache over the terrain service.
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
*/
export function createGroundCaster({
terrain,
step = GROUND_CAST_STEP_DEG,
lift = GROUND_CAST_LIFT_M,
maxCorners = GROUND_CAST_MAX_CORNERS,
cacheMax = GROUND_CAST_CACHE_MAX,
}) {
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
throw new TypeError('Ground casting requires a terrain service');
/** "i,j" grid corner → ellipsoidal ground height in metres. */
const heights = new Map();
/** Prepares run one at a time, so neighbouring tiles share their corners. */
let queue = Promise.resolve();
const cornerKey = (i, j) => `${i},${j}`;
function missingCorners(points) {
const missing = new Map();
for (const point of points) {
const [lon, lat] = point;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const i0 = Math.floor(lon / step);
const j0 = Math.floor(lat / step);
for (let di = 0; di <= 1; di++)
for (let dj = 0; dj <= 1; dj++) {
const key = cornerKey(i0 + di, j0 + dj);
if (!heights.has(key) && !missing.has(key))
missing.set(key, { i: i0 + di, j: j0 + dj });
}
}
return [...missing.entries()];
}
async function resolve(points, signal) {
if (signal?.aborted) return false;
// Evict before counting, or we could drop corners this request counted
// as cached. Oldest first, so the tiles still on screen keep theirs.
for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key);
}
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° is asked for at its wrapped longitude.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)),
lat: Number((j * step).toFixed(6)),
})),
{ signal },
);
let complete = true;
missing.forEach(([key], n) => {
const result = results?.[n];
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
heights.set(key, result.ellipsoid);
else complete = false;
});
return complete && !signal?.aborted;
}
/**
* Fetch the grid corners around the points. True when all can be cast;
* false when some are unknown, too many, or aborted. Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
function prepare(points, { signal } = {}) {
const run = queue.then(() => resolve(points, signal)).catch(() => false);
queue = run;
return run;
}
/**
* `prepare` for lines at both densities: the finer mesh points can fall in
* cells the coarse ones skip, and a missing cell leaves the line draped.
*/
function prepareLines(lines, options) {
return prepare(
lines.flatMap((coords) => [
...densifyLine(coords),
...densifyLine(coords, MESH_DENSIFY_DEG),
]),
options,
);
}
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
function groundAt(lon, lat) {
const x = lon / step;
const y = lat / step;
const i0 = Math.floor(x);
const j0 = Math.floor(y);
const h00 = heights.get(cornerKey(i0, j0));
const h10 = heights.get(cornerKey(i0 + 1, j0));
const h01 = heights.get(cornerKey(i0, j0 + 1));
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
if (h00 === undefined || h10 === undefined) return null;
if (h01 === undefined || h11 === undefined) return null;
const fx = x - i0;
const fy = y - j0;
const south = h00 + (h10 - h00) * fx;
const north = h01 + (h11 - h01) * fx;
return south + (north - south) * fy;
}
/**
* Densified line as flat [lon, lat, height, ...]; null until every corner is
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/
function castLine(coords, { meshAt = null } = {}) {
const rows = [];
const points = meshAt
? densifyLine(coords, MESH_DENSIFY_DEG)
: densifyLine(coords);
for (const [lon, lat] of points) {
const dem = groundAt(lon, lat);
if (dem === null) return null;
rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null });
}
const heights = refineHeights(rows, { lift });
const flat = [];
rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i]));
return flat;
}
return { prepare, prepareLines, groundAt, castLine };
}
+370
View File
@@ -0,0 +1,370 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GROUND_CAST_LIFT_M,
MESH_LIFT_M,
refineHeights,
SURFACE_TERRAIN_ENTER_M,
SURFACE_TERRAIN_EXIT_M,
createGroundCaster,
densifyLine,
nextSurfaceMode,
} from './groundCast.js';
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
const calls = [];
return {
calls,
async resolveEllipsoidalGround(coords) {
calls.push(coords);
if (fail) throw new Error('proxy down');
return coords.map(({ lon, lat }) => ({
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
source,
}));
},
};
}
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
const view = { photoreal: true, available: true };
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
assert.equal(
nextSurfaceMode('draped', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'terrain',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: SURFACE_TERRAIN_EXIT_M + 1,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, heightM: null }),
'draped',
);
});
test('densifyLine splits long segments and keeps the vertices', () => {
const line = [
[10, 50],
[10.002, 50],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], [10, 50]);
assert.deepEqual(dense.at(-1), [10.002, 50]);
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
const short = [
[10, 50],
[10.0001, 50],
];
assert.deepEqual(densifyLine(short, 0.0005), short);
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [
[179.9999, 0],
[-179.9999, 0],
];
assert.deepEqual(densifyLine(short), short);
const line = [
[179.999, 50.0005],
[-179.999, 50.0005],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], line[0]);
assert.deepEqual(dense.at(-1), line[1]);
for (const [lon] of dense)
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
// Westward across the date line too.
const back = densifyLine([...line].reverse(), 0.0005);
assert.equal(back.length, 5);
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
});
test('the caster interpolates cached grid corners and adds the lift', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
assert.equal(caster.groundAt(10.0005, 50.0005), null);
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
assert.equal(terrain.calls.length, 1);
assert.equal(terrain.calls[0].length, 4);
// Centre of the cell: mean of 100, 101, 102, 103.
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
// A cast line sits the lift above it.
const point = [10.0005, 50.0005];
const [, , height] = caster.castLine([point, point]);
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
// Same cell again: nothing to fetch.
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
assert.equal(terrain.calls.length, 1);
});
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
assert.equal(caster.castLine(line), null);
await caster.prepareLines([line]);
const flat = caster.castLine(line);
assert.equal(flat.length % 3, 0);
assert.ok(flat.length >= 6);
assert.equal(flat[0], 10.0001);
assert.equal(flat[1], 50.0001);
assert.ok(flat[2] > 100 && flat[2] < 110);
});
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
const fallback = createGroundCaster({
terrain: fakeTerrain({ source: 'geoid-fallback' }),
});
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
assert.equal(fallback.groundAt(10.0005, 50.0005), null);
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
assert.equal(down.groundAt(10.0005, 50.0005), null);
});
test('too many corners are not requested, and aborted prepares do nothing', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
const wide = [];
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
assert.equal(await caster.prepare(wide), false);
assert.equal(terrain.calls.length, 0);
const controller = new AbortController();
controller.abort();
assert.equal(
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
false,
);
assert.equal(terrain.calls.length, 0);
});
test('prepares run one at a time so neighbours share corners', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
const [a, b] = await Promise.all([
caster.prepare([[10.0005, 50.0005]]),
caster.prepare([[10.0015, 50.0005]]),
]);
assert.equal(a, true);
assert.equal(b, true);
// The second cell shares its west corners with the first.
assert.equal(terrain.calls[1].length, 2);
});
test('the caster needs a terrain service', () => {
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
});
test('refineHeights follows the mesh where it is the road', () => {
// A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it.
const heights = refineHeights([
{ dem: 100, mesh: 94 },
{ dem: 100, mesh: 102 },
{ dem: 100, mesh: null },
]);
assert.deepEqual(heights, [
94 + MESH_LIFT_M,
102 + MESH_LIFT_M,
100 + GROUND_CAST_LIFT_M,
]);
});
test('refineHeights carries the road under a canopy from both sides', () => {
// Road 1 m and 3 m above bare earth either side of a tree 12 m tall.
const heights = refineHeights([
{ dem: 50, mesh: 51 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 53 },
]);
assert.equal(heights[0], 51 + MESH_LIFT_M);
assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9);
assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9);
assert.equal(heights[3], 53 + MESH_LIFT_M);
});
test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => {
assert.deepEqual(
refineHeights([
{ dem: 20, mesh: 35 },
{ dem: 20, mesh: 35 },
]),
[20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M],
);
// A probe kilometres under the ground is not a road.
assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [
20 + GROUND_CAST_LIFT_M,
]);
});
test('castLine refines heights with sampled mesh and densifies finer', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
await caster.prepareLines([line]);
const plain = caster.castLine(line);
const meshed = caster.castLine(line, { meshAt: () => 98 });
assert.ok(meshed.length > plain.length, 'finer spacing with the mesh');
for (let i = 2; i < meshed.length; i += 3)
assert.equal(meshed[i], 98 + MESH_LIFT_M);
// Unsampled points keep the bare-earth height.
const unsampled = caster.castLine(line, { meshAt: () => undefined });
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
});
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 8,
cacheMax: 5,
});
// One cell's four corners are cached...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more, and the first
// cell must survive the eviction.
const line = [
[10.0002, 50.0005],
[10.0018, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
assert.ok(caster.castLine(line), 'the whole line casts');
});
test('a full cache drops its oldest corners, not every corner', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 4,
cacheMax: 12,
});
// Three cells apart from each other, four corners each: the cache is full.
const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [
lon,
50.0005,
]);
for (const cell of cells.slice(0, 3))
assert.equal(await caster.prepare([cell]), true);
// A fourth makes room by dropping the first cell only.
assert.equal(await caster.prepare([cells[3]]), true);
assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped');
for (const cell of cells.slice(1))
assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept');
});
test('cancelling a prepare cancels its terrain request', async () => {
const seen = [];
const caster = createGroundCaster({
terrain: {
async resolveEllipsoidalGround(coords, options) {
seen.push(options?.signal);
return coords.map(() => ({ ellipsoid: 100, source: 'reearth' }));
},
},
});
const controller = new AbortController();
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal });
assert.equal(seen.length, 1);
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
});
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
// Seeded walks with 50–300 m segments: the finer mesh points can land in
// grid cells the coarse points skip.
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
let failures = 0;
for (let n = 0; n < 300; n++) {
const caster = createGroundCaster({ terrain: fakeTerrain() });
const line = [[10 + random() * 0.01, 50 + random() * 0.01]];
for (let k = 0; k < 10; k++) {
const [lon, lat] = line[line.length - 1];
const length = (50 + random() * 250) / 111_000;
const angle = random() * 2 * Math.PI;
line.push([
lon + length * Math.cos(angle),
lat + length * Math.sin(angle),
]);
}
assert.equal(await caster.prepareLines([line]), true);
if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++;
}
assert.equal(failures, 0);
});
test('a line across the date line casts along the date line', async () => {
// Ground that rises northward only, so both sides of ±180° agree.
const calls = [];
const terrain = {
async resolveEllipsoidalGround(coords) {
calls.push(...coords);
return coords.map(({ lat }) => ({
ellipsoid: 100 + (lat - 50) * 2000,
source: 'reearth',
}));
},
};
const caster = createGroundCaster({ terrain, step: 0.001 });
const line = [
[179.9995, 50.0005],
[180, 50.0005],
[-179.9995, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
for (const meshAt of [null, () => undefined]) {
const flat = caster.castLine(line, { meshAt });
assert.ok(flat, 'every point casts');
assert.ok(
flat.length / 3 <= 8,
`${flat.length / 3} points, not a line round the world`,
);
for (let i = 0; i < flat.length; i += 3) {
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
}
}
// Only ground near the date line is asked for, and every longitude is a
// real one (the far corner of a point on 180° is asked for at -179.999°).
assert.ok(calls.length <= 16, `${calls.length} corners`);
for (const { lon } of calls)
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
});
+552
View File
@@ -0,0 +1,552 @@
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/**
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
providers: definitions,
services = {},
}) {
const definitionsFrozen = validateProviders(definitions);
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
// Mesh samples follow the application's rules: a real bare-earth prior,
// its mesh window, and a tileset that has finished streaming.
parts.meshSampler = parts.groundCaster
? createMeshSampler({
getViewer: () => state.viewer,
groundAt: parts.groundCaster.groundAt,
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
})
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.follow = createCameraFollow(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
parts.viewerHost = createViewerHost(context);
parts.hasSelectedSequence = () =>
[...state.providers.values()].some(
(entry) => entry.instance.sequenceStats?.()?.selectedId,
);
parts.clearSequences = () => {
for (const entry of state.providers.values())
entry.instance.clearSequence?.();
};
parts.selection = createSelection(context);
let notifyQueued = false;
function notify() {
if (notifyQueued) return;
notifyQueued = true;
queueMicrotask(() => {
notifyQueued = false;
const snapshot = getUIState();
for (const listener of [...state.listeners]) {
try {
listener(snapshot);
} catch (error) {
console.warn('[Data:StreetLevel] listener error:', error);
}
}
});
}
state.notify = notify;
function providerContext(entry) {
return Object.freeze({
services: state.services,
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
groundCaster: parts.groundCaster,
meshSampler: parts.meshSampler,
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/**
* Show a provider error, or withdraw it (null). A withdrawal clears
* only this provider's own error, never a newer one from elsewhere.
*/
reportError: (message) => {
if (message) {
state.street.error = entry.reportedError = message;
} else {
if (!entry.reportedError) return;
if (state.street.error === entry.reportedError)
state.street.error = null;
entry.reportedError = null;
}
notify();
},
},
});
}
for (const def of definitionsFrozen) {
const entry = {
def,
instance: null,
on: true,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
};
entry.instance = def.create(providerContext(entry));
state.providers.set(def.id, entry);
}
let mapStack = null;
let unsubscribeMapStack = null;
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
function syncFollowAvailability() {
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
if (available === state.street.followAvailable) return;
state.street.followAvailable = available;
if (!available && state.street.follow) parts.follow.setFollow(false);
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
const caster = parts.groundCaster;
const height = cameraHeightAboveGround(state.viewer, {
groundAt: caster.groundAt,
});
const lon = (carto.longitude * 180) / Math.PI;
const lat = (carto.latitude * 180) / Math.PI;
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
caster.prepare([[lon, lat]]).then((ready) => {
if (ready) scheduleSurfaceSync();
});
return height;
}
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
available,
photoreal,
heightM: available && photoreal ? cameraHeightForSurface() : null,
});
if (mode === state.surface) return;
state.surface = mode;
parts.meshSampler?.setEnabled(mode === 'terrain');
parts.marker.setSurface(mode);
for (const entry of state.providers.values())
entry.instance.setSurface?.(mode);
notify();
}
let surfaceTimer = null;
function scheduleSurfaceSync() {
clearTimeout(surfaceTimer);
surfaceTimer = setTimeout(syncSurface, 150);
}
let removeSurfaceListeners = null;
function watchSurface(viewer) {
removeSurfaceListeners?.();
const camera = viewer?.camera;
if (!camera?.changed || !camera?.moveEnd) {
removeSurfaceListeners = null;
return;
}
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
removeSurfaceListeners = () => {
removeChanged();
removeEnd();
};
}
function unwatchSurface() {
removeSurfaceListeners?.();
removeSurfaceListeners = null;
clearTimeout(surfaceTimer);
surfaceTimer = null;
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
async function refreshStatus(entry) {
try {
entry.status = await entry.instance.status();
} catch {
entry.status = null;
}
notify();
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry);
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
}
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
/** The nearest-image lookup in flight; any newer user action aborts it. */
let nearestLookup = null;
function abortNearest() {
nearestLookup?.abort();
nearestLookup = null;
}
function openImage(providerId, imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
}
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (next) activate(entry);
else deactivate(entry);
}
notify();
return true;
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
const resolved = resolveFilter(filter);
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
notify();
}
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
label: entry.def.label,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
// A rejected key gates the provider exactly like a missing one.
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
error: stats.error || null,
color: entry.def.colors.coverage,
};
});
}
function sequenceSnapshot() {
// The provider showing the open image answers first.
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
};
}
return { providerId: null, selectedId: null, images: 0, loading: false };
}
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
enabled: state.enabled,
filter: state.filter,
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
getSourceAvailability() {
const available = definitionsFrozen.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
reason: available
? null
: 'No street-level imagery providers configured',
};
},
init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
refreshStatus(entry);
}
console.log('[Data:StreetLevel] Initialized');
},
enable(viewer) {
state.enabled = true;
state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchSurface(viewer);
syncSurface();
notify();
},
disable() {
state.enabled = false;
abortNearest();
unwatchSurface();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncSurface();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
},
async update() {
return state.enabled;
},
destroy(viewer = state.viewer) {
layer.disable();
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.meshSampler?.destroy();
parts.follow.destroy();
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
state.listeners.clear();
state.viewer = null;
state.initialized = false;
state.destroyed = true;
},
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
: keyLabel
: coverage.error,
loadingLabel,
};
},
/**
* The application's camera authority: `run(noun, move)`, the deferred
* `begin(noun)` / `reassert(generation)` pair and
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
* photo claims it when opening starts and frames only if it still owns
* it once loaded. FOLLOW stops when another feature takes the camera.
*/
attachNavigation(navigation) {
parts.follow.attachNavigation(navigation);
},
/** The application map stack; FOLLOW is available only on Google 3D. */
attachMapStackController(controller) {
unsubscribeMapStack?.();
mapStack = controller || null;
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncSurface();
},
/** Share-link and stored state: provider switches plus the filter. */
getParams() {
return encodeParams({
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
filter: state.filter,
});
},
setParams(params = {}) {
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
setCoverageFilter(decoded.filter);
return true;
},
// ── Public surface used by the panel ────────────────────────────────
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
state.listeners.add(listener);
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element provider viewers render into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
async openNearest(point) {
const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
const lookup = new AbortController();
nearestLookup = lookup;
const { signal } = lookup;
state.street.loading = true;
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
let imageId = null;
try {
imageId = await entry.instance.nearestImage(
{ lat: view.lat, lon: view.lon },
{ signal },
);
} catch (error) {
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
if (signal.aborted) return false;
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect it everywhere on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
setFollow: (enabled) => parts.follow.setFollow(enabled),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
providerId = state.providers.keys().next().value,
) {
return state.providers
.get(providerId)
?.instance.selectSequence?.(sequenceId);
},
clearSequence: () => parts.clearSequences(),
refreshCoverage() {
for (const entry of activeEntries()) entry.instance.refreshCoverage();
},
};
return layer;
}
+498
View File
@@ -0,0 +1,498 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display.
*/
function fakeViewer() {
const credits = [];
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
let flight = null;
const flights = { started: 0, cancelled: 0 };
const stopFlight = () => {
const current = flight;
flight = null;
current?.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera: {
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = options;
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
},
};
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider() {
const provider = fakeProvider();
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
let emit = null;
instance.viewer.onPose = (listener) => {
emit = listener;
return () => {
emit = null;
};
};
instance.viewer.open = async (imageId) => {
provider.calls.open.push(imageId);
emit?.({
providerId: 'mapillary',
imageId,
position: { lon: -121.49, lat: 38.58 },
bearing: 90,
});
};
return instance;
};
return provider;
}
/** nearestImage lookups that answer when the test says, with their signals. */
function slowLookups() {
const lookups = [];
const nearestImage = (point, { signal } = {}) => {
const answer = deferred();
lookups.push({ point, signal, answer });
return answer.promise;
};
return { lookups, nearestImage };
}
/** A map stack controller that can be switched between stacks. */
function fakeMapStack(initial) {
let active = initial;
const listeners = new Set();
return {
getActiveId: () => active,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
switchTo(id) {
active = id;
for (const listener of listeners) listener();
},
listenerCount: () => listeners.size,
};
}
test('FOLLOW is available only while the Google 3D map stack is active', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
assert.equal(
layer.getUIState().street.followAvailable,
false,
'unknown stack: off',
);
const stack = fakeMapStack('esri-imagery');
layer.attachMapStackController(stack);
assert.equal(layer.getUIState().street.followAvailable, false);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
stack.switchTo('photoreal');
assert.equal(layer.getUIState().street.followAvailable, true);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, true);
stack.switchTo('osm');
assert.equal(layer.getUIState().street.followAvailable, false);
assert.equal(
layer.getUIState().street.follow,
false,
'leaving Google 3D stops following',
);
});
test('the layer lets go of the map stack when destroyed or re-attached', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
const first = fakeMapStack('photoreal');
layer.attachMapStackController(first);
assert.equal(first.listenerCount(), 1);
const second = fakeMapStack('photoreal');
layer.attachMapStackController(second);
assert.equal(first.listenerCount(), 0);
assert.equal(second.listenerCount(), 1);
layer.destroy();
assert.equal(second.listenerCount(), 0);
});
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(shown(), [], 'hidden with the provider');
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'every credit goes with the layer');
});
test('getStats tells a rejected key from a missing one', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
provider.stats.loading = true;
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
assert.equal(layer.getStats().keyRequired, false);
provider.stats.keyRequired = true;
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
// A rejected key gates the provider exactly like a missing one.
provider.stats.keyRequired = false;
provider.stats.keyRejected = true;
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const ui = layer.getUIState();
assert.equal(ui.keyRequired, true);
assert.equal(ui.keyRejected, true);
assert.equal(ui.providers[0].keyRequired, true);
});
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(provider.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setProviderEnabled('mapillary', false);
assert.equal(provider.calls.deactivate, 1);
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.providerId, null);
});
test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
const { street } = layer.getUIState();
assert.equal(street.loading, false);
assert.match(street.error, /Open the Street Level panel/);
assert.equal(provider.calls.mount, 0);
});
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.disable();
answer.resolve('img1');
assert.equal(await opening, false);
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
const { street } = layer.getUIState();
assert.equal(street.open, false);
assert.equal(street.loading, false);
assert.equal(street.error, null);
});
test('openNearest skips a provider switched off during its lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.setProviderEnabled('mapillary', false);
answer.resolve('img1');
assert.equal(await opening, false);
assert.deepEqual(provider.calls.open, []);
assert.equal(layer.getUIState().street.loading, false);
});
test('a provider withdraws only the error it reported', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
const { actions } = provider.context();
actions.reportError('Sequence images unavailable');
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
actions.reportError(null);
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
// The viewer's own error is not the provider's to clear.
layer.attachViewerHost(null);
actions.reportError('Sequence images unavailable');
await layer.openImage('mapillary', 'img1');
actions.reportError(null);
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
layer.disable();
assert.equal(viewer.flights.cancelled, 1, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img2');
layer.destroy();
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
// A search result flies the globe elsewhere before the framing lands.
viewer.camera.flyToBoundingSphere(null, {});
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
});
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
lookups[1].answer.resolve('newer');
assert.equal(await newer, true);
lookups[0].answer.resolve('older');
assert.equal(await older, false);
assert.deepEqual(provider.calls.open, ['newer']);
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
assert.equal(lookups[1].signal?.aborted, false);
});
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
// A cone click goes through the provider's openImage action.
assert.equal(await provider.context().actions.openImage('picked'), true);
lookups[0].answer.resolve('nearest');
assert.equal(await lookup, false);
assert.deepEqual(provider.calls.open, ['picked']);
assert.equal(lookups[0].signal?.aborted, true);
});
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.closeViewer();
// The aborted fetch rejects, as fetch does; that is not the user's error.
lookups[0].answer.reject(new DOMException('aborted', 'AbortError'));
assert.equal(await closed, false);
let { street } = layer.getUIState();
assert.deepEqual(
[street.open, street.loading, street.error],
[false, false, null],
);
assert.equal(lookups[0].signal?.aborted, true, 'closing aborts the request');
const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.disable();
layer.enable(viewer);
lookups[1].answer.resolve('img1');
assert.equal(await disabled, false, 'nor does it open once back on');
assert.deepEqual(provider.calls.open, []);
({ street } = layer.getUIState());
assert.deepEqual(
[street.open, street.loading, street.error],
[false, false, null],
);
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async (query, options) => {
requests.push(options);
return [{ id: 7, is_pano: false, captured_at: 10 }];
},
};
const instance = createMapillaryProvider({ source }).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { openImage() {}, reportError() {} },
});
const { signal } = new AbortController();
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
'7',
);
assert.equal(requests[0]?.signal, signal);
});
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'flat',
sinceDays: 0,
});
assert.equal(
provider.filters.at(-1).sinceMs,
null,
'providers drop the cut-off',
);
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
/** A layer initialised on a stand-in viewer but never enabled. */
function initialisedLayer(t, providers) {
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
return { layer, viewer };
}
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
const provider = fakeProvider();
const prewarmed = [];
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
instance.viewer.prewarm = async (host) => prewarmed.push(host);
return instance;
};
const { layer, viewer } = initialisedLayer(t, [provider]);
layer.attachViewerHost({});
layer.setProviderEnabled('mapillary', false);
layer.setProviderEnabled('mapillary', true);
layer.setParams({ mapillary: false });
layer.setParams({ mapillary: true });
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.deepEqual(prewarmed, [], 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what activates it, once.
layer.enable(viewer);
assert.equal(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 1);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
+131
View File
@@ -0,0 +1,131 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
let last = null;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
}
// It skips the depth test: hide it while it is behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => (state.marker.billboard ? [state.marker.billboard] : []),
onChange: requestRender,
});
function ensure(viewer) {
if (state.marker.collection) return;
state.marker.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.marker.collection);
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
}
/**
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
if (state.surface !== 'terrain' || !ground) return null;
const { lon, lat } = position;
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
});
return null;
}
const mesh = parts.meshSampler?.meshAt(lon, lat);
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
return refineHeights([{ dem, mesh }])[0];
}
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
horizon.stop();
requestRender();
return;
}
const height = castHeight(position);
const cartesian = Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
height ?? 0,
);
const heightReference =
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
if (!state.marker.billboard) {
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }),
imageId: 'sl-position',
width: 40,
height: 40,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else {
state.marker.billboard.heightReference = heightReference;
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
horizon.update([state.marker.billboard]);
requestRender();
}
function setVisible(visible) {
if (state.marker.collection) state.marker.collection.show = visible;
requestRender();
}
function destroy(viewer) {
horizon.stop();
const collection = state.marker.collection;
if (!collection) return;
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
viewer?.scene?.primitives?.remove(collection);
state.marker.collection = null;
state.marker.billboard = null;
}
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
}
return {
ensure,
set,
clear: () => set(null),
setVisible,
setSurface,
destroy,
};
}
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup({ parts = {}, surface = 'draped' } = {}) {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
const viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
primitives: { add: (p) => p, remove() {} },
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
const state = {
services: {},
viewer,
surface,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
show: true,
add: (options) => ({ ...options }),
removeAll() {},
},
billboard: null,
},
};
const marker = createMarker({ state, parts });
const frame = () => {
for (const listener of [...preRender]) listener();
};
return { state, view, preRender, marker, frame };
}
test('the photo marker is hidden behind the globe, and shows from its side', () => {
const { state, view, preRender, marker, frame } = setup();
// Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90);
const billboard = state.marker.billboard;
assert.equal(billboard.show, false, 'not drawn through the globe');
// Fly round to its side: it shows.
view.lon = 58.51;
view.lat = -38.58;
frame();
assert.equal(billboard.show, true);
// Moved to the far side again, without the camera moving.
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(billboard.show, false, 'culled where it now stands');
marker.clear();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
test('destroying the marker stops the horizon cull', () => {
const { marker, preRender } = setup();
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(preRender.size, 1);
marker.destroy();
assert.equal(preRender.size, 0);
});
/**
* Bare earth from a ground caster (null until its cell is fetched) and the
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
*/
function terrainParts({ dem = null, mesh } = {}) {
const ground = { dem, prepares: [] };
const sampler = { mesh, requests: [], listeners: [] };
return {
ground,
sampler,
parts: {
groundCaster: {
groundAt: () => ground.dem,
prepare(points) {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
ground.prepares.push({ points, resolve });
return promise;
},
},
meshSampler: {
meshAt: () => sampler.mesh,
request: (points) => sampler.requests.push(points),
onSampled: (listener) => sampler.listeners.push(listener),
},
},
};
}
const heightOf = (billboard) =>
Cesium.Cartographic.fromCartesian(billboard.position).height;
const SPOT = { lon: -121.49, lat: 38.58 };
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
});
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
const { parts, sampler } = terrainParts({ dem: 10 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
// The sample lands: the marker moves onto the road surface.
sampler.mesh = 10.5;
for (const listener of sampler.listeners) listener();
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
});
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
const { parts, ground } = terrainParts({ mesh: 11 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(
billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
});
test('ground that arrives after the marker moved on does not move it back', async () => {
const { parts, ground } = terrainParts();
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 0);
const elsewhere = { lon: -121.5, lat: 38.6 };
marker.set(elsewhere, 0);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
const placed = Cesium.Cartographic.fromCartesian(
state.marker.billboard.position,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
);
});
test('draped mode clamps the marker to the globe whatever the ground says', () => {
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
const { state, marker } = setup({ parts, surface: 'draped' });
marker.set(SPOT, 0);
assert.equal(
state.marker.billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(ground.prepares.length, 0);
});
+371
View File
@@ -0,0 +1,371 @@
import * as Cesium from 'cesium';
import { metresBetween, whenIdle } from './view.js';
/**
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
* mesh floor, a probe waits for the visible tileset to finish streaming and a
* sample counts only within the shared window around a real bare-earth height;
* anything else is a miss, retried later. Probes hit 3D tilesets only.
*/
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
export const MESH_CELL_DEG = 0.0001;
/** Cells farther than this from the point under the camera are not probed. */
export const MESH_SAMPLE_RADIUS_M = 900;
/** Main-thread time per idle slice, taking requests in and ranking included. */
export const MESH_SAMPLE_BUDGET_MS = 6;
/** The queue is re-ranked once the camera has moved this far, in metres. */
export const MESH_RERANK_M = 100;
/** A cell whose probe missed is tried again after this long. */
export const MESH_MISS_RETRY_MS = 8000;
/** While the visible tileset is streaming, the next probe waits this long. */
export const MESH_STREAMING_WAIT_MS = 500;
/**
* A sampled cell is probed again once the camera is half as far from it as at
* its last probe (finer tiles have loaded by then), down to this distance:
* at most a handful of probes per cell.
*/
export const MESH_REFRESH_MIN_M = 40;
/** A refreshed sample that moved less than this is not redrawn. */
const MESH_REFRESH_CHANGE_M = 0.5;
/** Cached cells (and remembered misses) before they are dropped and refilled. */
const MESH_CACHE_MAX = 80_000;
/** Listeners hear about new samples at most this often. */
const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
/** Add a queued cell to a min-heap on `distance`. */
function heapPush(heap, cell) {
let i = heap.push(cell) - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (heap[parent].distance <= cell.distance) break;
heap[i] = heap[parent];
i = parent;
}
heap[i] = cell;
}
/** Restore the heap below `i` after its cell was replaced. */
function siftDown(heap, i) {
const cell = heap[i];
const n = heap.length;
for (;;) {
let child = 2 * i + 1;
if (child >= n) break;
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
child++;
if (heap[child].distance >= cell.distance) break;
heap[i] = heap[child];
i = child;
}
heap[i] = cell;
}
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
if (heap.length) {
heap[0] = last;
siftDown(heap, 0);
}
return top;
}
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
function overlays(scene) {
const out = [];
const primitives = scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive);
}
return out;
}
/**
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
* visible tileset has finished streaming.
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
*/
export function createMeshSampler({
getViewer,
groundAt = null,
withinPrior = null,
tilesReady = null,
budgetMs = MESH_SAMPLE_BUDGET_MS,
radiusM = MESH_SAMPLE_RADIUS_M,
cacheMax = MESH_CACHE_MAX,
now = () => performance.now(),
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/** cell key → camera distance (m) at the cell's last probe. */
const probedFrom = new Map();
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
let queue = [];
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
let intake = [];
/** Where the camera was when the queue was ranked. */
let rankedFrom = null;
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
let probeMs = 1;
const listeners = new Set();
let enabled = false;
let running = false;
let fresh = [];
let notifyTimer = null;
/** The sampled mesh height for a point, or undefined when not sampled. */
function meshAt(lon, lat) {
return heights.get(meshCellKey(lon, lat));
}
/** The point under the camera, with the camera's height, or null. */
function cameraCentre() {
const carto = getViewer()?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
height: carto.height,
};
}
/** Camera distance (m) to a cell at `height`, from its ground distance. */
function cameraDistance(ground, centre, height) {
const above = (centre.height ?? 0) - (height ?? 0);
return Number.isFinite(above)
? Math.hypot(ground, Math.max(0, above))
: ground;
}
/** A sampled cell is probed again once the camera is half as far as before. */
function dueForRefresh(key, distance) {
const last = probedFrom.get(key);
return last > MESH_REFRESH_MIN_M && distance < last / 2;
}
/** A probe result the application would accept, or undefined. */
function validated(height, lon, lat) {
if (!Number.isFinite(height)) return undefined;
if (!groundAt) return height;
const prior = groundAt(lon, lat);
if (!Number.isFinite(prior)) return undefined;
return !withinPrior || withinPrior(height, prior) ? height : undefined;
}
/**
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
*/
function request(points) {
if (!enabled || !points?.length) return;
const centre = cameraCentre();
if (!centre) return;
intake.push({ points, next: 0, centre });
schedule();
}
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
function takeIn(until) {
const time = Date.now();
while (intake.length) {
const pending = intake[0];
const { points, centre } = pending;
while (pending.next < points.length) {
if ((pending.next & 255) === 255 && now() > until) return;
const [lon, lat] = points[pending.next++];
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = meshCellKey(lon, lat);
if (wanted.has(key)) continue;
const cell = {
key,
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
distance: 0,
};
const ground = metresBetween(cell, centre);
if (ground > radiusM) continue;
if (
heights.has(key) &&
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
)
continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > time) continue;
misses.delete(key);
}
cell.distance = metresBetween(cell, rankedFrom);
wanted.set(key, cell);
heapPush(queue, cell);
}
intake.shift();
}
}
/**
* Rank the queue from where the camera is now, dropping cells it has left
* out of range: one linear heapify, run only after the camera has moved.
*/
function rerank(centre) {
rankedFrom = centre;
const kept = [];
for (const cell of queue) {
cell.distance = metresBetween(cell, centre);
if (cell.distance > radiusM) wanted.delete(cell.key);
else kept.push(cell);
}
queue = kept;
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
}
function clearQueue() {
intake = [];
wanted.clear();
queue = [];
rankedFrom = null;
}
function schedule() {
if (running || !enabled || !(queue.length || intake.length)) return;
running = true;
whenIdle(step, 300);
}
function emit() {
notifyTimer = null;
const batch = fresh;
fresh = [];
for (const listener of [...listeners]) {
try {
listener(batch);
} catch (error) {
console.warn('[Data:StreetLevel] mesh listener error:', error);
}
}
}
function step(deadline) {
running = false;
// The slice starts now: taking requests in and ranking count too.
const started = now();
const until =
started +
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
const scene = getViewer()?.scene;
const centre = cameraCentre();
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
// Mid-stream tiles answer with coarse heights: wait for the stream.
if (tilesReady && !tilesReady(scene)) {
running = true;
setTimeout(() => {
running = false;
schedule();
}, MESH_STREAMING_WAIT_MS);
return;
}
// Nearest first, ranked from where the camera was; out-of-range cells
// are dropped (they stay bare earth).
let worked = false;
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
rerank(centre);
worked = true;
}
if (intake.length) {
// Leave room for a probe, so a stream of requests cannot starve them.
takeIn(until - probeMs);
worked = true;
}
const exclude = overlays(scene);
let probed = 0;
while (queue.length) {
// Stop before a probe would overrun the slice. A slice that did
// nothing else always probes once, so the queue drains.
if ((probed || worked) && now() + probeMs > until) break;
const { key, lon, lat } = heapPop(queue);
wanted.delete(key);
// Ranked from up to MESH_RERANK_M away: check the range from here.
const ground = metresBetween({ lon, lat }, centre);
if (ground > radiusM) continue;
probed++;
const probeStarted = now();
let height;
try {
height = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(lon, lat),
exclude,
);
} catch {
height = undefined;
}
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
height = validated(height, lon, lat);
const previous = heights.get(key);
if (height === undefined) {
// A miss, or a failed refresh: the old sample (if any) and the
// distance it was taken from stay, and the probe is retried after
// the same cooldown.
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
continue;
}
if (previous === undefined && heights.size >= cacheMax) forget();
misses.delete(key);
heights.set(key, height);
probedFrom.set(key, cameraDistance(ground, centre, height));
if (
previous === undefined ||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
)
fresh.push([lon, lat]);
}
if (fresh.length && !notifyTimer)
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
schedule();
}
function forget() {
heights.clear();
probedFrom.clear();
misses.clear();
}
/** Probe only while overlays are cast (Google 3D at street zoom). */
function setEnabled(on) {
enabled = on === true;
if (!enabled) clearQueue();
else schedule();
}
/** Hear about newly sampled cells, as [lon, lat] cell centres. */
function onSampled(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function destroy() {
enabled = false;
forget();
clearQueue();
listeners.clear();
clearTimeout(notifyTimer);
notifyTimer = null;
}
return { meshAt, request, setEnabled, onSampled, destroy };
}
+405
View File
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
MESH_CELL_DEG,
MESH_MISS_RETRY_MS,
MESH_REFRESH_MIN_M,
MESH_SAMPLE_BUDGET_MS,
MESH_STREAMING_WAIT_MS,
createMeshSampler,
} from './meshSampler.js';
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
import { metresBetween } from './view.js';
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
const RAD = Math.PI / 180;
/** A scene whose mesh is 100 m everywhere except where `holes` say. */
function fakeViewer({ holes = () => false } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const overlay = { name: 'lines' };
const probes = [];
const scene = {
sampleHeightSupported: true,
primitives: {
length: 2,
get: (i) => [tileset, overlay][i],
},
sampleHeight(carto, exclude) {
const lon = carto.longitude / RAD;
const lat = carto.latitude / RAD;
probes.push({ lon, lat, exclude });
return holes(lon, lat) ? undefined : 100;
},
};
return {
probes,
overlay,
viewer: {
scene,
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD },
},
},
};
}
test('cells are probed once, nearest first, with overlays excluded', async () => {
const { viewer, probes, overlay } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([
[10.004, 50],
[10.001, 50],
[10.00101, 50.00001], // same ~11 m cell as the previous point
]);
await settle(1000);
assert.equal(probes.length, 2);
assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first');
assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit');
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.equal(sampler.meshAt(10.00101, 50.00001), 100);
assert.deepEqual(heard, [2]);
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 2, 'a sampled cell is not probed again');
sampler.destroy();
});
test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => {
const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 0, 'disabled: nothing queued');
sampler.setEnabled(true);
sampler.request([
[10.05, 50], // ~3.6 km away
[10.005, 50], // a hole: tiles not streamed
]);
await settle();
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.05, 50), undefined);
assert.equal(sampler.meshAt(10.005, 50), undefined);
assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG);
sampler.destroy();
});
/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */
function probeNow(t) {
t.mock.timers.tick(20);
}
test('a missed cell is probed again after the retry window, not before', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'probed, and missed');
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'within the window: not probed');
t.mock.timers.tick(100 + 1);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'after the window: probed again');
sampler.destroy();
});
test('remembered misses are bounded like the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
sampler.setEnabled(true);
for (const lon of [10.001, 10.002, 10.003]) {
sampler.request([[lon, 50]]);
probeNow(t);
}
assert.equal(probes.length, 3);
// The third miss overflowed the list, which was dropped: the first cell
// is asked about again rather than remembered forever.
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 4);
sampler.destroy();
});
test('destroy forgets the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
assert.equal(sampler.meshAt(10.001, 50), undefined);
});
/**
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
* and each read of the camera `cameraMs` (work done before any probe).
*/
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const fake = fakeViewer({ holes });
const clock = { now: 0 };
const { scene, camera } = fake.viewer;
const probe = scene.sampleHeight;
scene.sampleHeight = (...args) => {
clock.now += probeMs;
return probe(...args);
};
const position = { ...camera.positionCartographic };
Object.defineProperty(camera, 'positionCartographic', {
get: () => {
clock.now += cameraMs;
return position;
},
});
const sampler = createMeshSampler({
getViewer: () => fake.viewer,
now: () => clock.now,
});
sampler.setEnabled(true);
/** Run one idle slice; its length on the fake clock. */
function slice() {
const started = clock.now;
probeNow(t);
return clock.now - started;
}
/** Put the camera over a point. */
function moveTo(lon, lat) {
position.longitude = lon * RAD;
position.latitude = lat * RAD;
}
return { ...fake, sampler, slice, moveTo };
}
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
function grid(lon, lat, n, step) {
const points = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
points.push([
lon + (i - (n - 1) / 2) * step,
lat + (j - (n - 1) / 2) * step,
]);
return points;
}
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002));
// The camera gets there before any idle slice runs.
moveTo(10.05, 50);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 0, 'nothing was queued for later');
// Asked again from there (as a camera move does), they are probed.
sampler.request([[10.05, 50]]);
slice();
assert.equal(probes.length, 1);
sampler.destroy();
});
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
assert.equal(probes.length, 25, 'every cell is probed in the end');
for (const length of lengths)
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
sampler.destroy();
});
test('a large queue is ranked once, not sorted again every slice', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015);
sampler.request(cells);
const sort = Array.prototype.sort;
let sorted = 0;
t.after(() => (Array.prototype.sort = sort));
Array.prototype.sort = function (...args) {
if (this.length >= 100) sorted += this.length;
return sort.apply(this, args);
};
for (let i = 0; i < 10; i++) slice();
const first = probes.map((probe) =>
metresBetween(probe, { lon: 10, lat: 50 }),
);
assert.ok(first.length >= 10, 'probes ran');
first.forEach((distance, i) =>
assert.ok(
!i || distance >= first[i - 1] - 0.01,
'nearest the camera first, across slices',
),
);
Array.prototype.sort = sort;
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
// The camera moves ~200 m east: the queue is ranked from there.
const done = probes.length;
moveTo(10.003, 50);
slice();
slice();
const after = probes
.slice(done)
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
assert.ok(after.length > 0);
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
sampler.destroy();
});
test('the sampling range is measured the short way round the date line', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0);
sampler.request([
[-179.9999, 0], // ~22 m away, across the date line
[179.99, 0], // ~1.1 km away, past the range
]);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
sampler.destroy();
});
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const probes = [];
const viewer = {
surface,
ready,
scene: {
sampleHeightSupported: true,
primitives: { length: 1, get: () => tileset },
sampleHeight(carto) {
probes.push(carto);
return viewer.surface;
},
},
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
},
};
return { viewer, probes };
}
function liveSampler(viewer, options = {}) {
return createMeshSampler({
getViewer: () => viewer,
groundAt: () => 75,
withinPrior: meshFloorSampleWithinPrior,
tilesReady: () => viewer.ready(),
...options,
});
}
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(sampler.meshAt(10.001, 50), 80);
// Finer tiles load: the surface is really 100 m.
viewer.surface = 100;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, 'no new probe from the same distance');
assert.equal(sampler.meshAt(10.001, 50), 80);
viewer.camera.positionCartographic.height = 250;
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
sampler.destroy();
});
test('refreshing stops once a cell was probed from close by', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle();
viewer.camera.positionCartographic.height = 85;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
sampler.destroy();
});
test('probes wait while the visible tileset is still streaming', async () => {
let ready = false;
const { viewer, probes } = liveViewer({ ready: () => ready });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 0);
ready = true;
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.001, 50), 80);
sampler.destroy();
});
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
const { viewer } = liveViewer({ surface: 300 });
const unknown = liveSampler(viewer, { groundAt: () => null });
unknown.setEnabled(true);
unknown.request([[10.001, 50]]);
const outside = liveSampler(viewer);
outside.setEnabled(true);
outside.request([[10.001, 50]]);
await settle();
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
assert.equal(
outside.meshAt(10.001, 50),
undefined,
'225 m above it: not kept',
);
unknown.destroy();
outside.destroy();
});
test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = liveViewer({ surface: 90, height: 700 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 90);
// The camera comes down to 20 m and the refresh misses (tiles in flux).
viewer.camera.positionCartographic.height = 110;
viewer.surface = undefined;
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays');
viewer.surface = 100;
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'not before the cooldown');
t.mock.timers.tick(100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 3, 'retried after it');
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
});
+43
View File
@@ -0,0 +1,43 @@
import { normalizeFilter } from './filter.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
}
/**
* Read params back, ignoring unknown providers and malformed values so a link
* from a build with more providers still applies.
* @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
*/
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
const switches = decodeIdSwitches(source.providerSwitches);
switches.delete('mapillary');
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
providers.set(id, source[id]);
}
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
return { providers, filter: normalizeFilter(next, filter) };
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeParams, encodeParams } from './params.js';
test('encodeParams writes one boolean per provider plus the filter', () => {
assert.deepEqual(
encodeParams({
providers: [
['mapillary', true],
['panoramax', false],
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
);
});
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
const current = { pano: 'all', sinceDays: 0 };
const decoded = decodeParams(
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
{ providerIds: ['mapillary'], filter: current },
);
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
const partial = decodeParams(
{ mapillary: '1', pano: 'weird' },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
);
assert.equal(partial.providers.size, 0, 'a string is not a switch');
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
const empty = decodeParams(null, {
providerIds: ['mapillary'],
filter: current,
});
assert.equal(empty.providers.size, 0);
assert.deepEqual(empty.filter, current);
});
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
const decoded = decodeParams(
{ sinceDays: 0 },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
);
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
// The provider switch off is a value too, not an absence.
assert.deepEqual(
[
...decodeParams(
{ mapillary: false },
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
).providers,
],
[['mapillary', false]],
);
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Route picked ids to the provider whose prefix they carry; the registry
* guarantees prefixes never overlap.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/
export function createPickRouter(getProviders, { positionId = null } = {}) {
function resolve(id) {
if (typeof id !== 'string' || !id) return null;
if (positionId && id === positionId)
return { providerId: null, instance: null, id };
for (const entry of getProviders())
if (id.startsWith(entry.def.pickPrefix))
return { providerId: entry.def.id, instance: entry.instance, id };
return null;
}
return {
ownsPick: (id) => resolve(id) !== null,
resolve,
};
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPickRouter } from './pickRouter.js';
const providers = [
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
];
test('ids are routed by prefix to their provider instance', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('mly:seq:1'), {
providerId: 'mapillary',
instance: providers[0].instance,
id: 'mly:seq:1',
});
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
assert.equal(router.ownsPick('mly:img:2'), true);
});
test('the core-owned marker is owned but routes to no provider', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('sl:pos'), {
providerId: null,
instance: null,
id: 'sl:pos',
});
assert.equal(router.ownsPick('sl:pos'), true);
});
test('foreign and malformed ids are not ours', () => {
const router = createPickRouter(() => providers);
for (const id of ['cctv:1', '', null, undefined, 42, {}])
assert.equal(router.ownsPick(id), false, String(id));
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
});
+37
View File
@@ -0,0 +1,37 @@
/** Identity and shared tuning for the provider-neutral Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
/** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
});
/** Colours every provider shares: the selection highlight and the marker. */
export const COLORS = Object.freeze({
selected: '#00d4ff',
position: '#ffb300',
});
/** Panorama modes the imagery filter understands. */
export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']);
/** Longest "captured since" window the filter accepts, in days (~100 years). */
export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/**
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4;
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeIdSwitches,
patchRequestedIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { validateProviders } from './registry.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
const filter = { pano: 'flat', sinceDays: 365 };
test('custom switches survive sharing and storage without depending on provider order', () => {
const providers = [
['panoramax', false],
['example', true],
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
options.providerSwitches,
);
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = options;
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
assert.ok(
params.toString().includes('0.r.example-1*panoramax-0'),
'custom switches need no percent escaping',
);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
]) {
const decoded = decodeParams(restored.options['street-level'], {
providerIds: ['example', 'mapillary', 'panoramax'],
filter: { pano: 'all', sinceDays: 0 },
});
assert.deepEqual(
[...decoded.providers],
[
['example', true],
['mapillary', false],
['panoramax', false],
],
);
assert.deepEqual(decoded.filter, filter);
assert.deepEqual(
[
...decodeParams(restored.options['street-level'], {
providerIds: ['panoramax'],
filter,
}).providers,
],
[['panoramax', false]],
);
assert.equal(
decodeParams(restored.options['street-level'], {
providerIds: [],
filter,
}).providers.size,
0,
);
}
});
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
for (const [lo, on] of [
['', true],
['0.m.0', false],
]) {
const state = decodeLayerStateParams(
new URLSearchParams({ v: '2', l: '0', lo }),
);
const restored = decodeParams(state.options['street-level'], {
providerIds: ['mapillary', 'example'],
filter,
});
assert.deepEqual([...restored.providers], [['mapillary', on]]);
}
});
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example-0*example-1',
'mapillary-0',
'__proto__-1',
'a-2',
'a-1*',
'a.b-1',
'a_b-0',
`${'a'.repeat(255)}-1`,
null,
{},
]) {
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary-0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
const switches = [
['provider-0', false],
['provider-1', true],
['a-b-c', true],
];
const encoded = encodeIdSwitches(switches);
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
const decoded = decodeIdSwitches(encoded);
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
});
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
const previous = `${'x'.repeat(254)}-1`;
assert.equal(normalizeIdSwitches(previous), previous);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
'alpha-0',
);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
undefined,
);
});
@@ -0,0 +1,917 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from '../../view.js';
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
tileBounds,
tilesForBbox,
} from '../../tileMath.js';
import {
COLORS,
COVERAGE_LINE_WIDTH_PX,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
COVERAGE_MOVE_DEBOUNCE_MS,
COVERAGE_OVERVIEW_MAX_TILES,
COVERAGE_OVERVIEW_POINT_PX,
KEY_REJECTED_MESSAGE,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
SEQUENCE_VIEW_NEAR_M,
SEQUENCE_VIEW_RANGE_MIN_M,
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
const PER_TILE_SEQUENCE_CAP = Math.floor(
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
);
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
function sequenceColor({ selected = false } = {}) {
return selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92);
}
/** Separates a multi-part sequence's part index from its id in pick ids. */
const PART_SEPARATOR = '~';
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
function partIds(sequence) {
return sequence.parts.map((_, index) =>
index
? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}`
: `${PICK_PREFIX.sequence}${sequence.id}`,
);
}
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId) {
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
return null;
const rest = pickId.slice(PICK_PREFIX.sequence.length);
const cut = rest.indexOf(PART_SEPARATOR);
return cut === -1 ? rest : rest.slice(0, cut);
}
/**
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// Move the camera's longitude to the box's side of the date line first:
// clamping 179.999 into a box at -179.99 would pick its far edge.
const lon =
centre.lon +
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
const nearest = {
lon: Math.min(box.east, Math.max(box.west, lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
* first, then swapped for lines cast to the bare earth (draped lines would
* land on roofs), so coverage never waits on terrain heights.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-coverage');
}
function ensureTerrainReady() {
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
}
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
/**
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
*/
function drawnSequences(entry) {
const drawn = [];
const current = filter();
for (const sequence of entry.sequenceList) {
if (drawn.length >= PER_TILE_SEQUENCE_CAP) break;
if (passesImageryFilter(sequence, current)) drawn.push(sequence);
}
return drawn;
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
const selectedId = state.sequence.selectedId ?? null;
const draped = [];
const cast = [];
for (const sequence of sequences) {
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor({ selected: sequence.id === selectedId }),
);
const ids = partIds(sequence);
sequence.parts.forEach((coordinates, index) => {
const flat = ground?.castLine(coordinates, { meshAt });
let positions;
try {
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
return;
}
if (positions.length < 2) return;
const geometry = flat
? new Cesium.PolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
})
: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
});
(flat ? cast : draped).push(
new Cesium.GeometryInstance({
geometry,
id: ids[index],
attributes: { color },
}),
);
});
}
const primitives = [];
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
selectedId,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
selectedId,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, draped, cast: cast.length };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw it cast.
* `castRequested` stops the redraw from casting again at once; a refresh
* `retry`s a tile whose lines are still draped (a failed or partial cast),
* and it is redrawn only when more of its lines can be cast.
*/
function castTile(entry, { retry = false } = {}) {
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = [...entry.sequences.values()].flatMap(
(sequence) => sequence.parts,
);
const caster = state.context.groundCaster;
caster.prepareLines(lines, { signal }).then(() => {
if (entry.castAbort?.signal === signal) entry.castAbort = null;
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Nothing new to cast: keep what is drawn until the next refresh.
// Counted like buildSequencePrimitives: a cast line needs two points.
const castable = lines.filter(
(coords) => caster.castLine(coords)?.length >= 6,
).length;
if (castable <= (entry.castLines || 0)) {
entry.castRequested = (entry.castLines || 0) > 0;
return;
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
redrawTile(entry);
requestMesh(entry);
});
}
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
const centre = meshCentre();
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
return;
const points = [];
for (const line of meshParts(entry)) {
if (!meshBoxInRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
if (entry.kind !== 'sequence' || !entry.bounds) continue;
const { west, south, east, north } = entry.bounds;
if (
batch.some(
([lon, lat]) =>
lon >= west && lon <= east && lat >= south && lat <= north,
)
)
state.coverage.remeshDirty.add(entry);
}
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
const wait = Math.max(
0,
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
);
state.coverage.remeshTimer = setTimeout(remesh, wait);
}
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
function remesh() {
state.coverage.remeshTimer = null;
state.coverage.remeshAt = Date.now();
if (!terrainMode() || !state.context.isActive()) {
state.coverage.remeshDirty.clear();
return;
}
const attached = new Set(state.coverage.tiles.values());
const [entry] = state.coverage.remeshDirty;
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
/**
* Remove a tile's previous primitives once all its current ones are ready
* (or after a few seconds); detaching the tile removes them at once.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passesImageryFilter(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: green,
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
}
return { collection, points: drawn };
}
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
entry.primitives = primitives;
entry.count = sequences.length;
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
// Fewer lines cast than last time: the caster has dropped heights this
// tile used (a full cache), so it must be cast again.
if (cast < (entry.castLines || 0)) entry.castRequested = false;
entry.castLines = cast;
entry.drapedLines = draped.length;
if (draped.length) castTile(entry);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
}
function removePrimitives(list) {
const scene = state.viewer?.scene;
for (const { primitive, onGround } of list || []) {
try {
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
notify();
try {
// Fetch and the one-time terrain-height table load run side by side,
// both handled from the start so an early tile failure is not unhandled.
const [bytes] = await Promise.all([
source.getTile('coverage', tile.z, tile.x, tile.y, {
signal: controller.signal,
}),
kind === 'sequence' ? ensureTerrainReady() : null,
]);
// Only an abort, a retire, a clear or a newer request for this tile
// discards the bytes; a refresh that still wants the tile keeps them.
const current = () =>
state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key);
if (
controller.signal.aborted ||
!current() ||
kind !== state.coverage.kind ||
tile.z !== state.coverage.zoom
)
return;
const decoded = decodeCoverageTile(bytes, tile);
const entry = { kind, primitive: null, primitives: [], count: 0 };
// Padded: vector tiles carry a small buffer past their edge.
const bounds = tileBounds(tile.x, tile.y, tile.z);
const pad = (bounds.east - bounds.west) * 0.05;
entry.bounds = {
west: bounds.west - pad,
east: bounds.east + pad,
south: bounds.south - pad,
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
requestRender();
} catch (error) {
if (
controller.signal.aborted ||
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
state.keyRejected = true;
state.coverage.lastError = KEY_REJECTED_MESSAGE;
}
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key);
if (!state.coverage.pending.size) purgeStale();
}
notify();
}
}
/**
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
*/
function holdFor(seconds) {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdUntil = Date.now() + seconds * 1000;
state.coverage.lastError = RATE_LIMITED_MESSAGE;
state.coverage.holdTimer = setTimeout(() => {
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
state.coverage.lastError = null;
refresh();
}, seconds * 1000);
}
/** Forget refusals when the provider goes off, so the next run asks again. */
function resetErrors() {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
function purgeStale() {
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = null;
if (!state.coverage.stale.size) return;
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
state.coverage.stale.clear();
requestRender();
}
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
}
state.coverage.tiles.clear();
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
}
function notify() {
state.context.notify();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
const ground = groundUnderCamera(viewer, {
groundAt: state.context.groundCaster?.groundAt,
});
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const sequenceZoom = coverageZoomForHeight(height);
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
const zoom = sequenceZoom ?? overviewZoom;
const kind = sequenceZoom ? 'sequence' : 'overview';
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver; Google 3D hides the globe) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
groundHeight: ground,
maxRange: Math.max(
SEQUENCE_VIEW_RANGE_MIN_M,
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
),
}
: {};
// Tilted street views rank tiles along the line of sight, from the ground
// under the camera to the ground at the centre of the screen.
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
let bbox = visibleBbox(viewer, {
...ranged,
// A tilted view's screen rows jump from the first metres to the horizon:
// keep the ground around the camera too. Looking down needs no help.
nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null,
});
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
'Point the camera at the globe for street-level coverage';
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
const { tiles } = tilesForBbox(bbox, zoom, {
limit:
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
focus: focus && { from: focus.nadir, to: focus.ahead },
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
// The aborted request no longer owns its key, so its `finally` will
// not settle it: settle it here.
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
for (const entry of state.coverage.tiles.values()) {
if (entry.castRequested) requestMesh(entry);
if (entry.drapedLines) castTile(entry, { retry: true });
}
notify();
}
function scheduleRefresh() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = setTimeout(
refresh,
COVERAGE_MOVE_DEBOUNCE_MS,
);
}
function attach(viewer) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
};
refresh();
}
function detach() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = null;
state.coverage.removeCameraListener?.();
state.coverage.removeCameraListener = null;
}
function clear() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
clearTimeout(state.coverage.remeshTimer);
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
horizon.stop();
requestRender();
}
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
requestRender();
notify();
}
/**
* Redraw tiles draped or cast for the new surface mode, then re-pick the
* zoom, since the ground height under the camera may have changed.
*/
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
refresh();
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
for (const instanceId of partIds(sequence)) {
try {
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
if (attributes)
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
value,
attributes.color,
);
} catch {
/* instance not in this primitive */
}
}
}
/** Every tile on the globe: the current zoom's and the stale zoom's. */
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of drawnRecords(entry)) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
if (selected) record.selectedId = id;
else if (record.selectedId === id) record.selectedId = null;
}
}
requestRender();
}
/**
* Bring a ready primitive's highlight up to the current selection, when
* it was built with another one (or none). True when it changed.
*/
function syncSelection(entry, record) {
const current = state.sequence.selectedId ?? null;
if (record.selectedId === current || !record.primitive.ready) return false;
const previous =
record.selectedId && entry.sequences.get(record.selectedId);
if (previous) recolorInstances(record.primitive, previous, false);
const next = current && entry.sequences.get(current);
if (next) recolorInstances(record.primitive, next, true);
record.selectedId = current;
return true;
}
/**
* A primitive cannot be recoloured until it is ready, so while any is
* building, catch each one up with the selection as it becomes ready.
*/
function watchSelection() {
const scene = state.viewer?.scene;
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
const stop = scene.postRender.addEventListener(() => {
let building = false;
let changed = false;
for (const entry of drawnEntries()) {
for (const record of entry.primitives || []) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
});
state.coverage.stopSelectionWatch = stop;
}
function stopSelectionWatch() {
state.coverage.stopSelectionWatch?.();
state.coverage.stopSelectionWatch = null;
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
*/
function sequenceCount() {
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
}
return {
attach,
detach,
refresh,
clear,
resetErrors,
rebuild,
setSurface,
recolorSequence,
sequenceCount,
};
}
@@ -0,0 +1,989 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
SWAP_MAX_WAIT_MS,
createCoverage,
meshBoxInRange,
} from './coverage.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import {
COLORS,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
} from './policy.js';
import { createGroundCaster } from '../../groundCast.js';
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
const RAD = Math.PI / 180;
// Node has no WebGL context to report line-width limits; a browser's
// context sets these on startup, and appearances validate against them.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A scene collection that remembers what is on the globe. */
function collection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** The centre of the z14 tile holding a point, so a small view is one tile. */
function tileCentre(lon, lat) {
const x = lonToTileX(lon, 14);
const y = latToTileY(lat, 14);
const { west, east, south, north } = tileBounds(x, y, 14);
return {
tile: { x, y, z: 14 },
lon: (west + east) / 2,
lat: (south + north) / 2,
};
}
/** A viewer looking straight down on a 0.004° square around `view`. */
function fakeViewer(view) {
const postRender = new Set();
const preRender = new Set();
return {
view,
postRender,
preRender,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
groundPrimitives: collection(),
primitives: collection(),
postRender: {
addEventListener(listener) {
postRender.add(listener);
return () => postRender.delete(listener);
},
},
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
camera: {
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
};
}
/** A source whose tile requests the test resolves, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getTile(layer, z, x, y, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
});
},
};
}
function setup({
surface = 'draped',
groundCaster = null,
meshSampler = null,
} = {}) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
const state = {
viewer,
services: {},
keyRequired: false,
context: {
filter: { pano: 'all', sinceMs: null },
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
getSurface: () => surface,
groundCaster,
meshSampler,
},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
debounceTimer: null,
removeCameraListener: null,
// Skip Cesium's one-time terrain table download.
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
const coverage = createCoverage({ state, source });
const bytes = encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'seq-1',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
],
});
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
const onGlobe = () =>
viewer.scene.groundPrimitives.items.size +
viewer.scene.primitives.items.size;
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
}
/** Run one frame's listeners, as Cesium's render loop would. */
function frame(listeners) {
for (const listener of [...listeners]) listener();
}
test('a superseded tile request cannot strand lines on the globe', async () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
const forTile = () => source.calls.filter((call) => call.key === tileKey);
coverage.refresh(); // street zoom: request #1 for the tile
assert.equal(forTile().length, 1);
viewer.view.height = 100_000; // zoom out: retire() drops request #1
coverage.refresh();
viewer.view.height = 900; // back in before #1 settles: request #2
coverage.refresh();
assert.equal(forTile().length, 2);
await settle();
// The superseded request finishes late. It must not settle request #2's
// key, or the next refresh would start a duplicate load.
forTile()[0].resolve(bytes);
await settle();
assert.equal(
state.coverage.pending.has(tileKey),
true,
'#2 still owns the key',
);
assert.equal(state.coverage.pending.size, 1, 'still loading');
coverage.refresh();
assert.equal(forTile().length, 2, 'no duplicate request');
forTile()[1].resolve(bytes);
await settle();
assert.equal(state.coverage.tiles.size, 1);
assert.ok(onGlobe() > 0, 'the tile drew its lines');
// Everything drawn can be removed: nothing is orphaned.
coverage.clear();
assert.equal(onGlobe(), 0);
});
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
heightsReady = true;
return true;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes, onGlobe } = setup({
surface: 'terrain',
groundCaster,
});
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = viewer.scene;
// Both are on the globe while the cast lines build: no blink.
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines added');
assert.ok(
[...primitives.items][0] instanceof Cesium.Primitive,
'cast lines are plain polylines at terrain height',
);
// Leaving mid-swap removes both sets, not just the new one.
coverage.clear();
assert.equal(onGlobe(), 0);
assert.equal(viewer.postRender.size, 0, 'the swap listener is gone');
});
test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => {
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return false; // terrain proxy down, or tile too big
},
castLine: () => null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
const added = [];
const add = viewer.scene.groundPrimitives.add.bind(
viewer.scene.groundPrimitives,
);
viewer.scene.groundPrimitives.add = (primitive) => {
added.push(primitive);
return add(primitive);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(added.length, 1, 'drawn once, draped; no second draped build');
assert.equal(viewer.scene.primitives.items.size, 0);
coverage.clear();
});
test('panning off a tile that is still loading stops counting it as loading', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
assert.equal(state.coverage.pending.size, 1);
// Same zoom, two tiles east: the first request is dropped, a new one starts.
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 2);
assert.equal(source.calls[0].signal.aborted, true);
assert.equal(
state.coverage.pending.size,
1,
'only the live request counts as loading',
);
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
source.calls[1].resolve(new Uint8Array());
await settle();
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
coverage.clear();
});
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
const { viewer, source, state, coverage } = setup();
viewer.view.height = 20_000_000;
coverage.refresh();
assert.equal(state.coverage.zoom, 0);
assert.equal(state.coverage.hint, '');
assert.deepEqual(
source.calls.map((call) => call.key),
['0/0/0'],
);
coverage.clear();
});
test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
const rejected = Object.assign(
new Error('Mapillary rejected the access token'),
{
keyRejected: true,
},
);
source.calls[0].reject(rejected);
await settle();
assert.equal(state.keyRejected, true);
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
// Turning the provider off forgets the verdict; the next run asks again.
coverage.clear();
coverage.resetErrors();
assert.equal(state.keyRejected, false);
assert.equal(state.coverage.lastError, null);
coverage.refresh();
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, state, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(state.coverage.lastError, /rate-limited/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
t.mock.timers.tick(30_000);
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
assert.equal(state.coverage.lastError, null);
coverage.clear();
coverage.resetErrors();
});
test('overview points behind the horizon are hidden, not drawn through the globe', async () => {
const { viewer, source, state, coverage } = setup();
const { view } = viewer;
view.height = 20_000_000;
Object.defineProperty(viewer.camera, 'positionWC', {
get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height),
});
coverage.refresh();
// One dot under the camera (Sacramento), one near its antipode.
source.calls[0].resolve(
encodeCoverageTile(
{ x: 0, y: 0, z: 0 },
{
overview: [
{ id: 'near', lon: -121.5, lat: 38.6 },
{ id: 'far', lon: 58.5, lat: -38.6 },
],
},
),
);
await settle();
const [entry] = state.coverage.tiles.values();
const points = [0, 1].map((i) => entry.primitive.get(i));
const west = (point) =>
Cesium.Cartographic.fromCartesian(point.position).longitude < 0;
const near = points.find(west);
const far = points.find((point) => !west(point));
frame(viewer.preRender);
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.5;
view.lat = -38.6;
frame(viewer.preRender);
assert.equal(near.show, false);
assert.equal(far.show, true);
coverage.clear();
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
});
test('a cast tile whose heights were evicted is cast again, not left draped', async () => {
const terrain = {
calls: 0,
async resolveEllipsoidalGround(coords) {
this.calls++;
return coords.map(() => ({ ellipsoid: 20, source: 'reearth' }));
},
};
// Room for one tile's corners at a time.
const groundCaster = createGroundCaster({
terrain,
maxCorners: 16,
cacheMax: 20,
});
let sampled = null;
const meshSampler = {
onSampled: (listener) => (sampled = listener),
request() {},
meshAt: () => undefined,
};
const { source, state, coverage, bytes, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const isCast = () => {
const [entry] = state.coverage.tiles.values();
return (
entry.primitives.length > 0 &&
entry.primitives.every(({ onGround }) => !onGround)
);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.ok(isCast(), 'cast once the heights are in');
// Another region fills the cache and evicts this tile's corners...
await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]);
assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null);
// ...then mesh samples redraw the tile, which can only drape now.
sampled([[centre.lon, centre.lat]]);
await new Promise((resolve) => setTimeout(resolve, 5));
coverage.refresh();
await settle();
await settle();
assert.ok(isCast(), 'the tile is cast again');
coverage.clear();
});
test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => {
let up = false;
let prepares = 0;
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
prepares++;
heightsReady = up;
return up;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(viewer.scene.primitives.items.size, 0, 'draped');
await settle();
assert.equal(prepares, 1, 'no retry loop while the proxy is down');
up = true; // the proxy is back
coverage.refresh();
await settle();
assert.equal(prepares, 2, 'the next refresh tries again');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added');
coverage.clear();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
const { state, source, coverage, centre } = setup();
const line = (n) => [
[
[centre.lon - 0.001, centre.lat + n * 1e-6],
[centre.lon + 0.001, centre.lat + n * 1e-6],
],
];
// 700 newer flat sequences, then 300 older 360° ones.
const sequences = [];
for (let i = 0; i < 700; i++)
sequences.push({
id: `flat-${i}`,
parts: line(i),
capturedAt: 2_000_000_000_000 + i,
});
for (let i = 0; i < 300; i++)
sequences.push({
id: `pano-${i}`,
parts: line(i),
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.context.filter = { pano: 'pano', sinceMs: null };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
const [entry] = state.coverage.tiles.values();
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
// Picking and recolouring look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.context.filter = { pano: 'all', sinceMs: null };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear();
});
/** The colour attribute value of a line, selected or not. */
function colourValue(selected) {
return Array.from(
Cesium.ColorGeometryInstanceAttribute.toValue(
selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
),
);
}
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
function controlled(record, ready) {
const control = { ready, attributes: { color: undefined } };
Object.defineProperty(record.primitive, 'ready', {
get: () => control.ready,
});
record.primitive.getGeometryInstanceAttributes = (id) =>
id === 'mly:seq:seq-1' ? control.attributes : undefined;
return control;
}
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
// Selected while its line is still building: nothing to recolour yet...
const first = controlled(entry.primitives[0], false);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.equal(first.attributes.color, undefined);
// ...so the highlight lands once it is ready.
first.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
coverage.rebuild();
const second = controlled(entry.primitives[0], false);
coverage.recolorSequence('seq-1', false);
state.sequence.selectedId = null;
second.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('a sequence that crosses a tile edge is counted once', async () => {
const { viewer, source, coverage, centre } = setup();
// Look straight down on the edge between this tile and its east neighbour.
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
viewer.view.lon = east;
coverage.refresh();
assert.equal(source.calls.length, 2, 'two tiles in view');
const across = [
[
[east - 0.001, centre.lat],
[east + 0.001, centre.lat],
],
];
for (const call of source.calls) {
const [, x, y] = call.key.split('/').map(Number);
call.resolve(
encodeCoverageTile(
{ x, y, z: 14 },
{
sequences: [
{ id: 'across', parts: across },
{ id: `only-${x}`, parts: across },
],
},
),
);
}
await settle();
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
coverage.clear();
});
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const context = setup({ surface: 'terrain', groundCaster });
context.coverage.refresh();
context.source.calls[0].resolve(context.bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = context.viewer.scene;
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines building');
const [cast] = primitives.items;
const control = { ready: false };
Object.defineProperty(cast, 'ready', { get: () => control.ready });
return { ...context, control };
}
test('the draped lines go once the cast lines are ready', async () => {
const { viewer, coverage, control } = await midSwap();
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
control.ready = true;
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
coverage.clear();
});
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
t.mock.timers.enable({ apis: ['Date'] });
const { viewer, coverage } = await midSwap();
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
t.mock.timers.tick(2);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1);
coverage.clear();
});
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
function tiltedCamera({ lon, lat, height, heading, pitch }) {
return rayCamera({
lon,
lat,
altitude: height,
heading,
pitch,
width: 100,
height: 100,
fovX: 60,
fovY: 40,
});
}
/**
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
const x = lonToTileX(-104.99, z);
const y = latToTileY(39.74, z);
const tile = tileBounds(x, y, z);
const lon = (tile.west + tile.east) / 2;
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
const above = 300;
const context = setup({ groundCaster: { groundAt: () => ground } });
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({
lon,
lat,
height: ground + above,
heading: 90,
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
});
context.coverage.refresh();
const keys = context.source.calls.map((call) => call.key);
/** Requested tiles as [dx, dy] from the tile under the camera. */
const offsets = keys.map((k) => {
const [, tx, ty] = k.split('/').map(Number);
return [tx - x, ty - y];
});
return {
...context,
keys,
offsets,
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
};
}
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.deepEqual(
keys.slice(0, 2),
[key(0, 0), key(1, 0)],
'the tile under the camera, then the one the screen centre looks at',
);
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
for (const [dx, dy] of offsets)
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
coverage.clear();
});
test('a street view toward the horizon ranks only the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range: the
// tiles are ranked around the camera, not along a line to the horizon.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
// Behind and ahead are equally near the camera (ties keep row order); a
// line of sight to the far ground would rank the tile ahead first.
assert.ok(
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
'the tile ahead is not pulled forward',
);
for (const [dx, dy] of offsets)
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
coverage.clear();
});
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], true);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
// Zoom out: the street tile stays on screen while the new zoom loads...
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
// ...and the selection is cleared meanwhile.
state.sequence.selectedId = null;
coverage.recolorSequence('seq-1', false);
assert.deepEqual(
Array.from(line.attributes.color),
colourValue(false),
'the retained line is not left cyan',
);
coverage.clear();
});
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], false);
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1);
frame(viewer.postRender);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
line.ready = true;
frame(viewer.postRender);
assert.deepEqual(
line.attributes.color && Array.from(line.attributes.color),
colourValue(true),
'the highlight lands on the retained line',
);
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('only lines within the mesh sampler range of the camera are asked for', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const requested = [];
const meshSampler = {
onSampled() {},
request: (points) => requested.push(...points),
meshAt: () => undefined,
};
const { source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'here',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
// ~1.1 km from the camera, in the tile's north-east corner.
{
id: 'corner',
parts: [
[
[east - 0.002, north - 0.002],
[east - 0.0005, north - 0.0005],
],
],
},
],
}),
);
await settle();
await settle();
assert.ok(requested.length > 0, 'the near line is asked for');
const farthest = Math.max(
...requested.map(([lon, lat]) =>
Math.hypot(
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
(lat - centre.lat) * 110_540,
),
),
);
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
coverage.clear();
});
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [old] = viewer.scene.groundPrimitives.items;
assert.ok(old, 'the z14 lines are drawn');
viewer.view.height = 3000; // zoom out to z13
coverage.refresh();
assert.equal(state.coverage.zoom, 13);
const fresh = source.calls.slice(1);
assert.ok(fresh.length > 0, 'the new zoom is loading');
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
assert.ok(viewer.scene.groundPrimitives.items.has(old));
for (const call of fresh) {
assert.ok(
viewer.scene.groundPrimitives.items.has(old),
'kept while a new tile is still loading',
);
call.resolve(new Uint8Array());
await settle();
}
// The last new tile is in: the old lines go now, long before the wait.
assert.equal(state.coverage.pending.size, 0);
assert.equal(state.coverage.stale.size, 0);
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
coverage.clear();
});
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
t.mock.timers.tick(28_000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
t.mock.timers.tick(1000);
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
coverage.clear();
coverage.resetErrors();
});
test('a line across the date line from the camera is in mesh range', () => {
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
const camera = { lon: 179.999, lat: 0 };
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(east, camera), true);
// The mirror case, from the other side.
const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true);
// ~2.2 km away across the line: out of range either way.
const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(far, camera), false);
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
const { source, state, coverage, tileKey } = setup();
let terrainLoaded;
state.coverage.terrainReady = new Promise((resolve) => {
terrainLoaded = resolve;
});
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
coverage.refresh();
source.calls
.find((call) => call.key === tileKey)
.reject(new Error('Mapillary tiles HTTP 500'));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
assert.deepEqual(unhandled, []);
} finally {
terrainLoaded();
await settle();
process.off('unhandledRejection', onUnhandled);
}
});
test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => {
// Two lines: the west one's heights arrive, the east one's fail at first.
let eastUp = false;
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return eastUp;
},
castLine: (coords) =>
coords[0][0] < centreLon || eastUp
? coords.flatMap(([lon, lat]) => [lon, lat, 30])
: null,
};
const { state, source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
});
const centreLon = centre.lon;
const line = (from, to) => [
[
[from, centre.lat],
[to, centre.lat],
],
];
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{ id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) },
{ id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) },
],
}),
);
await settle();
await settle();
const [entry] = state.coverage.tiles.values();
assert.equal(prepares, 1);
assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial');
await settle();
assert.equal(prepares, 1, 'no retry loop while the lookup fails');
// A refresh while still failing asks again but rebuilds nothing.
const drawn = entry.primitives;
coverage.refresh();
await settle();
assert.equal(prepares, 2);
assert.equal(entry.primitives, drawn, 'no rebuild without new heights');
eastUp = true; // the terrain proxy recovers
coverage.refresh();
await settle();
assert.equal(prepares, 3, 'the next refresh tries the draped line again');
assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast');
coverage.clear();
});
@@ -0,0 +1,127 @@
import { PbfWriter } from 'pbf';
const zigzag = (value) => (value << 1) ^ (value >> 31);
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
const n = 2 ** z;
const rad = (lat * Math.PI) / 180;
const tx = ((lon + 180) / 360) * n;
const ty =
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
return [Math.round((tx - x) * extent), Math.round((ty - y) * extent)];
}
/** One MVT layer writer with deduplicated keys and appended values. */
function layerWriter(name, extent) {
const keys = new Map();
const values = [];
const features = [];
function tags(properties) {
const out = [];
for (const [key, value] of Object.entries(properties)) {
if (value === undefined || value === null) continue;
if (!keys.has(key)) keys.set(key, keys.size);
out.push(keys.get(key), values.length);
values.push(value);
}
return out;
}
return {
add(type, geometry, properties, id) {
features.push({ type, geometry, tags: tags(properties), id });
},
write(pbf) {
pbf.writeVarintField(15, 2);
pbf.writeStringField(1, name);
pbf.writeVarintField(5, extent);
for (const feature of features)
pbf.writeMessage(
2,
(f, out) => {
if (Number.isInteger(f.id)) out.writeVarintField(1, f.id);
out.writePackedVarint(2, f.tags);
out.writeVarintField(3, f.type);
out.writePackedVarint(4, f.geometry);
},
feature,
);
for (const key of keys.keys()) pbf.writeStringField(3, key);
for (const value of values)
pbf.writeMessage(
4,
(v, out) => {
if (typeof v === 'boolean') out.writeBooleanField(7, v);
else if (typeof v === 'number') out.writeVarintField(5, v);
else out.writeStringField(1, String(v));
},
value,
);
},
};
}
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
function lineGeometry(parts) {
const out = [];
let cx = 0;
let cy = 0;
for (const part of parts) {
if (part.length < 2) continue;
const [x0, y0] = part[0];
out.push(9, zigzag(x0 - cx), zigzag(y0 - cy));
cx = x0;
cy = y0;
out.push(((part.length - 1) << 3) | 2);
for (const [x, y] of part.slice(1)) {
out.push(zigzag(x - cx), zigzag(y - cy));
cx = x;
cy = y;
}
}
return out;
}
/**
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
* the hermetic QA gate.
* @param {{x: number, y: number, z: number}} tile
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
*/
export function encodeCoverageTile(
tile,
{ sequences = [], overview = [], extent = 4096 } = {},
) {
const pbf = new PbfWriter();
if (sequences.length) {
const layer = layerWriter('sequence', extent);
for (const sequence of sequences)
layer.add(
2,
lineGeometry(
sequence.parts.map((part) =>
part.map((point) => lonLatToTileLocal(point, tile, extent)),
),
),
{
id: sequence.id,
captured_at: sequence.capturedAt ?? Date.now(),
is_pano: sequence.isPano === true,
},
);
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
if (overview.length) {
const layer = layerWriter('overview', extent);
for (const point of overview) {
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
layer.add(1, [9, zigzag(px), zigzag(py)], {
id: point.id,
captured_at: point.capturedAt ?? Date.now(),
is_pano: point.isPano === true,
});
}
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
return new Uint8Array(pbf.finish());
}
@@ -0,0 +1,70 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* points. The z14 `image` layer, which can hold >150k points, is skipped.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
*/
export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
const sequenceLayer = tile.layers.sequence;
if (sequenceLayer) {
for (let i = 0; i < sequenceLayer.length; i++) {
const feature = sequenceLayer.feature(i);
const props = feature.properties || {};
// A sequence with a capture gap is a multi-line: keep each part as its
// own line, so no straight segment is drawn across the gap.
const parts = [];
for (const line of feature.loadGeometry()) {
const part = line.map((point) =>
tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z),
);
if (part.length >= 2) parts.push(part);
}
if (!parts.length) continue;
result.sequences.push({
id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`),
imageId: props.image_id != null ? String(props.image_id) : null,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
onFoot: props.foot === true,
quality: Number.isFinite(props.quality_score)
? props.quality_score
: null,
parts,
});
}
}
const overviewLayer = tile.layers.overview;
if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) {
const feature = overviewLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
overviewLayer.extent,
x,
y,
z,
);
result.overview.push({
id: String(props.id ?? feature.id ?? i),
lon,
lat,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PbfWriter } from 'pbf';
import { decodeCoverageTile } from './decode.js';
const zigzag = (value) => (value << 1) ^ (value >> 31);
/** One line part: MoveTo the first point, LineTo the rest (relative). */
function linePart(points) {
const out = [9, zigzag(points[0][0]), zigzag(points[0][1])];
out.push(((points.length - 1) << 3) | 2);
for (let i = 1; i < points.length; i++)
out.push(
zigzag(points[i][0] - points[i - 1][0]),
zigzag(points[i][1] - points[i - 1][1]),
);
return out;
}
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
function sampleTile({ geometry = null } = {}) {
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'sequence');
layer.writeVarintField(5, 4096);
for (const key of ['id', 'captured_at', 'is_pano'])
layer.writeStringField(3, key);
layer.writeMessage(
4,
(__, value) => value.writeStringField(1, 'seq-42'),
null,
);
layer.writeMessage(
4,
(__, value) => value.writeVarintField(5, 1_700_000_000),
null,
);
layer.writeMessage(
4,
(__, value) => value.writeBooleanField(7, true),
null,
);
layer.writeMessage(
2,
(__, feature) => {
feature.writeVarintField(1, 7);
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
feature.writeVarintField(3, 2); // LineString
feature.writePackedVarint(
4,
geometry || [
9,
zigzag(100),
zigzag(100), // MoveTo
10,
zigzag(200),
zigzag(200), // LineTo ×1
],
);
},
null,
);
},
null,
);
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'overview');
layer.writeVarintField(5, 4096);
layer.writeStringField(3, 'is_pano');
layer.writeMessage(
4,
(__, value) => value.writeBooleanField(7, false),
null,
);
layer.writeMessage(
2,
(__, feature) => {
feature.writeVarintField(1, 3);
feature.writePackedVarint(2, [0, 0]);
feature.writeVarintField(3, 1); // Point
feature.writePackedVarint(4, [9, zigzag(2048), zigzag(2048)]);
},
null,
);
},
null,
);
return new Uint8Array(writer.finish());
}
test('sequences and overview points decode into lon/lat records', () => {
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
assert.equal(decoded.sequences.length, 1);
const [sequence] = decoded.sequences;
assert.equal(sequence.id, 'seq-42');
assert.equal(sequence.capturedAt, 1_700_000_000);
assert.equal(sequence.isPano, true);
assert.equal(sequence.parts.length, 1);
assert.equal(sequence.parts[0].length, 2);
const [[lon0, lat0], [lon1, lat1]] = sequence.parts[0];
assert.ok(
lon0 > -180 && lon0 < 0 && lon1 > lon0,
'west half of tile 0/0 at z1',
);
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
assert.equal(decoded.overview.length, 1);
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
});
test('an empty tile decodes to empty lists', () => {
assert.deepEqual(
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
overview: [],
},
);
});
test('a sequence with a capture gap keeps its parts as separate lines', () => {
// Two parts with a gap between (300,300) and (1000,1000); the second part's
// MoveTo is relative to the end of the first, as in the MVT encoding.
const first = [
[100, 100],
[300, 300],
];
const second = [
[700, 700],
[900, 700],
];
const geometry = [
...linePart(first),
9,
zigzag(second[0][0] - first[1][0]),
zigzag(second[0][1] - first[1][1]),
((second.length - 1) << 3) | 2,
zigzag(second[1][0] - second[0][0]),
zigzag(second[1][1] - second[0][1]),
];
const [sequence] = decodeCoverageTile(sampleTile({ geometry }), {
x: 0,
y: 0,
z: 1,
}).sequences;
assert.equal(sequence.parts.length, 2);
assert.equal(sequence.parts[0].length, 2);
assert.equal(sequence.parts[1].length, 2);
// No part ends where the next begins: the gap is not bridged.
assert.notDeepEqual(sequence.parts[0].at(-1), sequence.parts[1][0]);
});
@@ -0,0 +1,220 @@
import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
} from './policy.js';
/** Per-provider mutable state, created once per `create()`. */
function createProviderState(context) {
return {
context,
services: context.services,
viewer: null,
keyRequired: false,
/** Coverage waits for the first status: no tile requests without a key. */
statusKnown: false,
/** Mapillary refused the token: request nothing until re-enabled. */
keyRejected: false,
status: null,
coverage: {
zoom: null,
/** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(),
/** Previous zoom's tiles, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */
pending: new Map(),
lastError: null,
/** While Mapillary rate-limits: no requests before this time (ms). */
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
}
/**
* Mapillary Street Level provider: tile coverage, Graph API image cones and
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
return Object.freeze({
id: MAPILLARY_PROVIDER_ID,
name: MAPILLARY_NAME,
label: MAPILLARY_LABEL,
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
create(context) {
const state = createProviderState(context);
const parts = {};
parts.coverage = createCoverage({ state, source, parts });
parts.sequences = createSequences({ state, source, parts });
const viewer = createMapillaryViewer({
source,
render: context.services?.render,
});
return {
async status() {
try {
state.status = await source.getStatus();
state.keyRequired = state.status?.configured !== true;
} catch {
state.status = null;
state.keyRequired = !source.hasToken();
}
const first = !state.statusKnown;
state.statusKnown = true;
// Coverage held back until now: draw it if the layer is on.
if (first) parts.coverage.refresh();
context.notify();
return { configured: !state.keyRequired };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.ensureCollections(cesiumViewer);
parts.sequences.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.setVisible(true);
parts.coverage.attach(cesiumViewer);
},
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(cesiumViewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.destroy(cesiumViewer);
},
refreshCoverage: () => parts.coverage.refresh(),
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
/** Core surface mode changed: redraw lines and cones draped or cast. */
setSurface() {
parts.coverage.setSurface();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
error: state.coverage.lastError,
keyRequired: state.keyRequired,
keyRejected: state.keyRejected,
};
},
handlePick(pickId) {
const sequenceId = sequenceIdFromPick(pickId);
if (sequenceId) {
parts.sequences.select(sequenceId);
return true;
}
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
return false;
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
}),
/**
* Nearest image that passes the imagery filter, or null. The core
* aborts `signal` once a newer request overtakes the lookup.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
const filter = context.getFilter();
const hit = byDistance.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
},
viewer,
};
},
});
}
@@ -0,0 +1,79 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
export const MAPILLARY_LABEL = 'MAPILLARY';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have for the provider. */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
'nearestImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
export const PICK_PREFIX = Object.freeze({
root: 'mly:',
sequence: 'mly:seq:',
image: 'mly:img:',
});
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary,
selected: SHARED_COLORS.selected,
});
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own';
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
/** Ground always covered around the camera at street zooms, in metres. */
export const SEQUENCE_VIEW_NEAR_M = 1_000;
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
/** Deep link to an image on mapillary.com, as the web app shares them. */
export function mapillaryImageUrl(imageId) {
const id = String(imageId || '').trim();
if (!id) return 'https://www.mapillary.com/app/';
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
}
/** Fields requested from the graph API. */
export const IMAGE_FIELDS =
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { validateProviders } from '../../registry.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { resolveFilter } from '../../filter.js';
import * as Cesium from 'cesium';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
function fakeSource({ nearest = [] } = {}) {
return {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async () => nearest,
};
}
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getFilter: typeof filter === 'function' ? filter : () => filter,
isActive: () => true,
notify: () => {},
actions: {
openImage: (id) => {
opened.push(id);
},
reportError: () => {},
},
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
// One colour per source: Mapillary is green everywhere it draws.
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({ source: fakeSource() }).create(
context,
);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
assert.deepEqual(instance.sequenceStats(), {
selectedId: null,
images: 0,
loading: false,
});
});
test('nearestImage honours the imagery filter and reports key status', async () => {
const nearest = [
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
];
const source = fakeSource({ nearest });
const flat = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
const pano = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'pano', sinceMs: null }),
);
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
const none = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
assert.deepEqual(await flat.status(), { configured: true });
assert.equal(flat.coverageStats().keyRequired, false);
});
test('any part of a multi-part sequence picks the whole sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('nearestImage picks the closest image, not the first the API returned', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('far', 2.0004, 1), // ~45 m east
at('near', 2.00005, 1), // ~5 m east
at('mid', 2, 1.0002), // ~22 m north
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
});
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
const day = 86_400_000;
const capturedAt = Date.now() - day / 2;
const source = fakeSource({
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
});
// The core's context resolves the stored filter whenever it is asked.
const instance = createMapillaryProvider({ source }).create(
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
);
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }),
null,
'two days on, the image is older than the window',
);
});
test('nearestImage measures the short way round the date line', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
'across',
);
// And from the other side.
const west = createMapillaryProvider({
source: fakeSource({
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
}),
}).create(fakeContext());
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
});
test('cone thinning across the date line drops images metres apart', () => {
const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
/** A viewer looking straight down on downtown Sacramento from 900 m. */
function streetViewer() {
const collection = () => ({
add: (item) => item,
remove: () => true,
contains: () => false,
get length() {
return 0;
},
});
const event = () => ({ addEventListener: () => () => {} });
const camera = Object.assign(
rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
}),
{ changed: event(), moveEnd: event() },
);
return {
camera,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
primitives: collection(),
groundPrimitives: collection(),
preRender: event(),
postRender: event(),
requestRender() {},
},
};
}
test('coverage asks for no tiles before the key status is known', async () => {
for (const configured of [false, true]) {
let answer;
const tiles = [];
const source = {
...fakeSource(),
hasToken: () => configured,
getStatus: () => new Promise((resolve) => (answer = resolve)),
getTile: (...args) => {
tiles.push(args);
return new Promise(() => {});
},
};
const instance = createMapillaryProvider({ source }).create(fakeContext());
const viewer = streetViewer();
instance.init(viewer);
const status = instance.status();
instance.activate(viewer);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured });
await status;
if (configured)
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
else {
assert.equal(tiles.length, 0, 'a key-less install never asks');
assert.equal(instance.coverageStats().keyRequired, true);
}
instance.destroy(viewer);
}
});
@@ -0,0 +1,322 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { refineHeights } from '../../groundCast.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import { meshCellKey } from '../../meshSampler.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
IMAGE_CONE_SIZE_PX,
PICK_PREFIX,
} from './policy.js';
const SPRITE_ID = 'street-level:mapillary-cones';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
/** Normalize a graph image record into the shape the cones use. */
export function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
return {
id: String(record.id),
lon: Number(coordinates[0]),
lat: Number(coordinates[1]),
compassAngle: Number(record.compass_angle) || 0,
capturedAt: Number(record.captured_at) || 0,
isPano: record.is_pano === true,
altitude: Number.isFinite(record.computed_altitude)
? record.computed_altitude
: null,
};
}
/** Drop images closer than the spacing to the previous kept one. */
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
const kept = [];
let last = null;
for (const image of images) {
if (!last || metresBetween(last, image) >= spacingM) {
kept.push(image);
last = image;
}
}
return kept;
}
/** Image cones for one selected sequence. */
export function createSequences({ state, source, parts }) {
const { render, sprites } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-sequence');
}
function notify() {
state.context.notify();
}
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
onChange: requestRender,
});
function ensureCollections(viewer) {
if (state.sequence.collection) return;
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
}
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
if (dems.includes(null)) {
ground
.prepare(images.map((image) => [image.lon, image.lat]))
.then((ready) => {
if (ready && state.sequence.images === images) renderCones(images);
});
return null;
}
const sampler = state.context.meshSampler;
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
mesh: sampler?.meshAt(image.lon, image.lat),
})),
);
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
const ring = imageConeGlyph({
size: 32,
color: COLORS.coverage,
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passesImageryFilter(image, filter)) return;
const height = heights?.[index] ?? null;
const billboard = collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
...placement(image, height),
image: image.isPano ? ring : cone,
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
horizon.update();
requestRender();
}
/**
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
}
if (!moved.length) return;
horizon.update(moved);
requestRender();
}
function remember(sequenceId, images) {
const { cache } = state.sequence;
cache.delete(sequenceId);
cache.set(sequenceId, images);
while (cache.size > SEQUENCE_CACHE_SIZE)
cache.delete(cache.keys().next().value);
}
/** Select a sequence: highlight its line and load its image cones. */
async function select(sequenceId) {
if (!sequenceId || !state.viewer) return;
// Already on screen, or already on its way.
if (
state.sequence.selectedId === sequenceId &&
(state.sequence.images.length || state.sequence.abort)
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.abort?.abort();
state.sequence.selectedId = sequenceId;
parts.coverage.recolorSequence(sequenceId, true);
const cached = state.sequence.cache.get(sequenceId);
if (cached) {
state.sequence.abort = null;
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
notify();
return;
}
// The previous sequence's cones must not stay clickable under the new
// highlight while this one loads, nor after its load fails.
clearCones();
requestRender();
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
notify();
try {
const records = await source.getSequenceImages(sequenceId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
const images = thinImages(
records
.map(normalizeSequenceImage)
.filter(Boolean)
.sort((a, b) => a.capturedAt - b.capturedAt),
);
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
} catch (error) {
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
parts.coverage.recolorSequence(sequenceId, false);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
}
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
state.sequence.abort = null;
}
notify();
}
}
function clearSelection() {
state.sequence.abort?.abort();
state.sequence.abort = null;
if (state.sequence.selectedId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
requestRender();
notify();
}
/** Re-draw the current sequence's cones (after an imagery filter change). */
function rerender() {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
// New mesh samples may move the cones onto (or off) the road surface.
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
requestRender();
}
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
viewer?.scene?.primitives?.remove(collection);
state.sequence.collection = null;
}
state.sequence.images = [];
state.sequence.selectedId = null;
}
return {
ensureCollections,
select,
clearSelection,
rerender,
setVisible,
destroy,
};
}
@@ -0,0 +1,325 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getSequenceImages(sequenceId, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ sequenceId, signal, resolve, reject });
});
},
/** The latest lookup for a sequence. */
last(sequenceId) {
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
},
};
}
/** Graph image records for a sequence, a few metres apart. */
function records(sequenceId, count = 2) {
return Array.from({ length: count }, (_, i) => ({
id: `${sequenceId}-${i}`,
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
compass_angle: 90,
captured_at: 1_700_000_000_000 + i,
}));
}
function setup() {
const source = deferredSource();
const colours = [];
const errors = [];
const state = {
services: {},
viewer: {},
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
actions: { reportError: (message) => errors.push(message) },
},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: cones(),
loading: false,
abort: null,
},
};
const parts = {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, colours, errors, drawn };
}
test('a failed sequence load leaves no stale cones and can be retried', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['A-0', 'A-1']);
sequences.select('B');
assert.deepEqual(drawn(), [], "A's cones go while B loads");
source.last('B').reject(new Error('Sequence images unavailable'));
await settle();
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
assert.deepEqual(colours.at(-1), ['B', false]);
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
// Clicking B again asks again.
sequences.select('B');
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
source.last('B').resolve(records('B'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
});
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
sequences.select('B');
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
source.last('B').resolve(records('B'));
await settle();
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.cache.has('A'), false);
assert.equal(state.sequence.loading, false);
});
test('a second click on a loading sequence does not start another lookup', async () => {
const { source, sequences } = setup();
sequences.select('A');
sequences.select('A');
assert.equal(source.calls.length, 1);
assert.equal(source.calls[0].signal.aborted, false);
});
test('a cached sequence is re-selected without a lookup', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
sequences.select('B');
source.last('B').resolve(records('B'));
await settle();
sequences.select('A');
assert.equal(source.calls.length, 2, 'A came from the cache');
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, 'A');
});
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
sequences.clearSelection();
assert.equal(source.last('A').signal.aborted, true);
assert.deepEqual(colours, [
['A', true],
['A', false],
]);
assert.equal(state.sequence.selectedId, null);
assert.equal(state.sequence.loading, false);
assert.equal(errors.at(-1), null, 'an error about it goes too');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
/**
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
* cones that log every change made to them.
*/
function terrainSetup() {
const context = setup();
const { state } = context;
const mesh = new Map();
const cell = (lon, lat) =>
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
let announce = null;
state.context.getSurface = () => 'terrain';
state.context.groundCaster = {
groundAt: () => 10,
prepare: async () => true,
};
state.context.meshSampler = {
request() {},
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
onSampled: (listener) => {
announce = listener;
return () => (announce = null);
},
};
const changes = [];
const items = [];
state.sequence.collection = {
items,
show: true,
add(options) {
changes.push(['add', options.id]);
const cone = new Proxy(options, {
set(target, key, value) {
changes.push([key, target.id]);
target[key] = value;
return true;
},
});
items.push(cone);
return cone;
},
removeAll() {
changes.push(['removeAll']);
items.length = 0;
},
};
// Created again, so it listens to this sampler.
const sequences = createSequences({
state,
source: context.source,
parts: { coverage: { recolorSequence() {} } },
});
/** Sample the mesh under a point, then announce it as the sampler does. */
function sample(lon, lat, height) {
const centre = [
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
];
mesh.set(cell(lon, lat), height);
announce([centre]);
}
const heightOf = (cone) =>
Cesium.Cartographic.fromCartesian(cone.position).height;
return { ...context, sequences, changes, items, sample, heightOf };
}
test('a mesh sample away from the selected sequence changes no cone', async () => {
const { source, sequences, changes, items, sample } = terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
assert.equal(items.length, 3);
changes.length = 0;
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
});
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
const { source, sequences, changes, items, sample, heightOf } =
terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
const before = items.map(heightOf);
changes.length = 0;
// The road under the second image is 2.5 m above the bare earth.
sample(-121.489, 38.58, 12.5);
assert.ok(
changes.every(([, id]) => id === 'mly:img:A-1'),
`only A-1 changed: ${JSON.stringify(changes)}`,
);
assert.ok(
changes.some(([key]) => key === 'position'),
'A-1 moved',
);
assert.equal(
changes.some(([key]) => key === 'removeAll' || key === 'add'),
false,
'the collection is not rebuilt',
);
const after = items.map(heightOf);
assert.ok(
Math.abs(after[1] - 13.5) < 0.01,
`A-1 stands on the road (${after[1]})`,
);
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
});
test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A');
// One image under the camera, one near its antipode.
source.last('A').resolve([
...records('A', 1),
{
id: 'A-far',
geometry: { coordinates: [58.51, -38.58] },
captured_at: 1_800_000_000_000,
},
]);
await settle();
const [near, far] = state.sequence.collection.items;
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
@@ -0,0 +1,148 @@
import {
IMAGE_FIELDS,
MAPILLARY_GRAPH_HOST,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
super(message);
this.name = 'MapillarySourceError';
this.status = status;
this.payload = payload;
this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key';
this.keyRejected = payload?.keyRejected === true;
const wait = Number(retryAfterSec ?? payload?.retryAfter);
this.retryAfterSec =
status === 429 ? (Number.isFinite(wait) && wait > 0 ? wait : 60) : null;
}
}
async function readJsonOrThrow(response, label) {
let payload = null;
try {
payload = await response.json();
} catch {
/* status is authoritative */
}
if (!response.ok)
throw new MapillarySourceError(
payload?.error?.message ||
payload?.error ||
`${label} HTTP ${response.status}`,
{ status: response.status, payload },
);
return payload;
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
} = {}) {
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
graph: MAPILLARY_GRAPH_HOST,
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
payload: { keyRequired: true },
});
const search = new URLSearchParams({ access_token: token, ...params });
const response = await fetchImpl(`${urls.graph}/${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
return {
get token() {
return token;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
const response = await fetchImpl(urls.status, {
signal,
cache: 'no-store',
});
return readJsonOrThrow(response, 'Mapillary status');
},
/** Raw protobuf bytes for one tile; an empty array means no data. */
async getTile(layer, z, x, y, { signal } = {}) {
const response = await fetchImpl(
`${urls.tiles}/${layer}/${z}/${x}/${y}`,
{
signal,
},
);
if (response.status === 204) return new Uint8Array(0);
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* ignore */
}
throw new MapillarySourceError(
payload?.error || `Tile HTTP ${response.status}`,
{
status: response.status,
payload,
retryAfterSec: response.headers?.get?.('retry-after') ?? undefined,
},
);
}
return new Uint8Array(await response.arrayBuffer());
},
async getSequenceImages(
sequenceId,
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
) {
const payload = await graph(
'images',
{
sequence_ids: String(sequenceId),
fields: SEQUENCE_IMAGE_FIELDS,
limit: String(limit),
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
async nearestImages(
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
{ signal } = {},
) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(Math.min(50, Math.max(1, radius))),
limit: String(Math.min(100, Math.max(1, limit))),
fields: IMAGE_FIELDS,
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillarySource, MapillarySourceError } from './source.js';
function fakeFetch(handler) {
const calls = [];
const fetchImpl = async (url, init = {}) => {
calls.push({ url: String(url), init });
return handler(String(url), init);
};
return { calls, fetchImpl };
}
const jsonResponse = (payload, status = 200) =>
new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
test('tiles go through the local proxy and never carry the token', async () => {
const { calls, fetchImpl } = fakeFetch(
() => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const bytes = await source.getTile('coverage', 14, 2662, 6285);
assert.deepEqual([...bytes], [1, 2, 3]);
assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285');
assert.doesNotMatch(calls[0].url, /MLY/);
});
test('an empty tile (204) is an empty byte array, not an error', async () => {
const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 }));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0);
});
test('a missing key on the proxy surfaces as keyRequired', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({ token: '', fetchImpl });
assert.equal(source.hasToken(), false);
await assert.rejects(
() => source.getTile('coverage', 14, 1, 1),
(error) =>
error instanceof MapillarySourceError &&
error.status === 503 &&
error.keyRequired === true,
);
});
test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.host, 'graph.mapillary.com');
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.match(url.searchParams.get('fields'), /geometry/);
const nearest = await source.nearestImages({
lat: 38.5,
lon: -121.5,
radius: 500,
limit: 500,
});
assert.equal(nearest.length, 1);
const nearestUrl = new URL(calls[1].url);
assert.equal(
nearestUrl.searchParams.get('radius'),
'50',
'radius is clamped to the API maximum',
);
assert.equal(nearestUrl.searchParams.get('limit'), '100');
});
test('graph calls without a token fail fast as keyRequired without a request', async () => {
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
const source = createMapillarySource({ token: '', fetchImpl });
await assert.rejects(
() => source.getSequenceImages('123'),
(error) =>
error instanceof MapillarySourceError && error.keyRequired === true,
);
assert.equal(calls.length, 0);
});
test('graph API errors carry the upstream message and status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
await assert.rejects(
() => source.getSequenceImages('999'),
(error) =>
error instanceof MapillarySourceError &&
error.status === 400 &&
/Invalid OAuth/.test(error.message),
);
});
test('the source exposes only imagery lookups', () => {
const source = createMapillarySource({ token: 'MLY|1|abc' });
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
assert.equal(source[gone], undefined, gone);
});
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
const rejectedSource = createMapillarySource({
token: 'MLY|1|abc',
fetchImpl: fakeFetch(() =>
jsonResponse(
{ error: 'Mapillary rejected the access token', keyRejected: true },
403,
),
).fetchImpl,
});
await assert.rejects(
() => rejectedSource.getTile('coverage', 14, 1, 1),
(error) =>
error.keyRejected === true &&
error.keyRequired === false &&
error.retryAfterSec === null,
);
const limitedSource = createMapillarySource({
token: 'MLY|1|abc',
fetchImpl: fakeFetch(
() =>
new Response(
JSON.stringify({ error: 'rate limited', retryAfter: 45 }),
{
status: 429,
headers: { 'retry-after': '30' },
},
),
).fetchImpl,
});
await assert.rejects(
() => limitedSource.getTile('coverage', 14, 1, 1),
(error) => error.retryAfterSec === 30 && error.keyRejected === false,
'the Retry-After header wins over the body',
);
});
@@ -0,0 +1,231 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
/**
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
* provider-neutral pose on every image, view or position change.
* @returns {import('../../registry.js').ViewerAdapter}
*/
export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
let pendingOpen = null;
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
let renderMode = 'letterbox';
/** Metadata of the image on screen; pov/position events reuse it. */
let current = null;
const listeners = new Set();
function requestRender() {
render?.governorRequestRender?.('mapillary-viewer');
}
function libraryRenderMode(mode) {
const { RenderMode } = Library || {};
if (!RenderMode) return undefined;
return mode === 'fill' ? RenderMode.Fill : RenderMode.Letterbox;
}
async function ensureLibrary() {
if (Library) return Library;
// Lazy CSS: Vite applies it before the import resolves, so the viewer is
// styled from its first frame without ~90 KB of render-blocking CSS.
const [library] = await Promise.all([
import('mapillary-js'),
import('mapillary-js/dist/mapillary.css'),
]);
Library = library;
return Library;
}
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
}
/** Read MapillaryJS image metadata into the neutral pose shape. */
function describe(image) {
return {
imageId: String(image.id),
isPano: image.cameraType === 'spherical',
capturedAt: image.capturedAt ?? null,
sequenceId: image.sequenceId ?? null,
altitude: Number.isFinite(image.computedAltitude)
? image.computedAltitude
: Number.isFinite(image.originalAltitude)
? image.originalAltitude
: null,
creator: image.creatorUsername || null,
};
}
async function publishPose(image) {
if (!viewer) return;
try {
const [lngLat, pov] = await Promise.all([
viewer.getPosition(),
viewer.getPointOfView(),
]);
// Closed (or never opened) while the pose was in flight: a late
// `image` event must not bring the photo back.
if (pendingOpen === null) return;
if (image) current = describe(image);
if (!current || !viewer) return;
emit({
providerId: MAPILLARY_PROVIDER_ID,
...current,
position: { lon: lngLat.lng, lat: lngLat.lat },
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0,
externalUrl: mapillaryImageUrl(current.imageId),
});
requestRender();
} catch {
/* viewer torn down mid-flight */
}
}
function ensureViewer(host) {
if (viewer && container === host) return viewer;
if (creating?.container === host) return creating.promise;
const promise = createViewer(host).finally(() => {
if (creating?.promise === promise) creating = null;
});
creating = { container: host, promise };
return promise;
}
async function createViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
// Unmounted (layer off) while the library loaded: a viewer built now
// would hold a WebGL context nobody releases.
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
viewer = new Viewer({
accessToken: source.token,
container: host,
component: {
cover: false,
bearing: true,
zoom: true,
attribution: true,
},
// The panel's ResizeObserver resizes the viewer, and skips it while it
// is hidden (0x0); MapillaryJS's own window tracking would resize a
// hidden viewer and ask for a tile at z=NaN.
trackResize: false,
renderMode: libraryRenderMode(renderMode),
});
container = host;
viewer.on('image', (event) => publishPose(event.image));
viewer.on('pov', () => publishPose(null));
viewer.on('position', () => publishPose(null));
return viewer;
}
function destroyViewer() {
if (viewer) {
try {
viewer.remove();
} catch {
/* already removed */
}
}
viewer = null;
container = null;
current = null;
}
return {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
await ensureViewer(host);
},
/** Resolves once the image is on screen and its first pose was emitted. */
async open(imageId) {
const id = String(imageId);
if (!container) throw new Error('Mapillary viewer is not mounted');
pendingOpen = id;
const instance = await ensureViewer(container);
if (pendingOpen !== id) return;
const image = await instance.moveTo(id);
if (pendingOpen !== id) return;
await publishPose(image);
},
close() {
pendingOpen = null;
current = null;
// A playing sequence would keep stepping through (and downloading)
// images in the hidden viewer.
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
},
unmount() {
pendingOpen = null;
// The next mount builds afresh rather than joining a stale build.
generation++;
creating = null;
destroyViewer();
},
resize() {
try {
viewer?.resize();
} catch {
/* no-op */
}
},
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
async prewarm(host) {
if (!host) return;
// An in-flight prewarm may give up (layer toggled mid-download): wait
// for it, then build if it did not.
if (prewarming) await prewarming;
if (viewer) return;
const run = (async () => {
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
}
})();
prewarming = run;
await run;
if (prewarming === run) prewarming = null;
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
const value = libraryRenderMode(renderMode);
if (viewer && value !== undefined) viewer.setRenderMode(value);
} catch {
/* viewer not ready */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
* @typedef {object} StreetLevelProvider
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
* @typedef {object} ProviderInstance
* @property {() => Promise<{configured: boolean}>} status
* @property {(viewer: object) => void} init Create collections, hidden.
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
* @property {(viewer: object) => void} destroy
* @property {() => void} refreshCoverage
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
* @property {ViewerAdapter} viewer
*
* @typedef {object} GroundCaster
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
*
* @typedef {object} ViewerAdapter
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
* @property {() => void} close Drop the image, keep the instance warm.
* @property {() => void} unmount Destroy the instance (host handed to another provider).
* @property {() => void} resize
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
*
* @typedef {object} StreetPose
* @property {string} providerId
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} tilt
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
*/
const REQUIRED = Object.freeze([
'id',
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
]);
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/**
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
if (!Array.isArray(providers))
throw new TypeError('Street Level providers must be an array');
const ids = new Set();
const prefixes = [];
for (const provider of providers) {
const label = provider?.id ?? '(unnamed)';
for (const key of REQUIRED)
if (provider?.[key] === undefined || provider?.[key] === null)
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
throw new TypeError(`Street Level provider id ${label} is not a slug`);
if (ids.has(provider.id))
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
if (typeof provider.create !== 'function')
throw new TypeError(
`Street Level provider ${label}: create is not a function`,
);
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
for (const other of prefixes)
if (
other.startsWith(provider.pickPrefix) ||
provider.pickPrefix.startsWith(other)
)
throw new TypeError(
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
);
if (!provider.credit?.html)
throw new TypeError(`Street Level provider ${label} needs a credit`);
if (
typeof provider.colors?.coverage !== 'string' ||
!provider.colors.coverage
)
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
try {
encodeIdSwitches(
providers
.filter(({ id }) => id !== 'mapillary')
.map(({ id }) => [id, true]),
);
} catch (error) {
if (!(error instanceof RangeError)) throw error;
throw new RangeError(
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
);
}
return Object.freeze([...providers]);
}
/**
* The key id every provider shares, or null if any is keyless or they differ
* (then each chip reports its own need).
* @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null}
*/
export function requiresKeyIdFor(providers) {
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
if (keys.size !== 1) return null;
return [...keys][0];
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: 'mapillary',
pickPrefix: 'mly:',
colors: { coverage: '#05cb63' },
credit: { html: 'Mapillary' },
create: () => ({}),
...overrides,
});
test('a valid list is frozen in registration order', () => {
const list = validateProviders([
def(),
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
]);
assert.ok(Object.isFrozen(list));
assert.deepEqual(
list.map((p) => p.id),
['mapillary', 'panoramax'],
);
});
test('an empty provider list is valid and has no key requirement', () => {
assert.deepEqual(validateProviders([]), []);
assert.ok(Object.isFrozen(validateProviders([])));
assert.equal(requiresKeyIdFor([]), null);
});
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders(null), /array/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
});
test('every required field is checked by name', () => {
for (const key of [
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
])
assert.throws(
() => validateProviders([def({ [key]: undefined })]),
new RegExp(`lacks ${key}`),
key,
);
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
assert.throws(
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
/coverage colour/,
);
assert.throws(
() => validateProviders([def({ pickPrefix: '' })]),
/pick prefix/,
);
});
test('overlapping pick prefixes are rejected either way round', () => {
assert.throws(
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
/overlaps/,
);
assert.throws(
() =>
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
/overlaps/,
);
assert.doesNotThrow(() =>
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
);
});
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
assert.equal(
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
'mapillary',
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
]),
null,
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
]),
null,
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
+184
View File
@@ -0,0 +1,184 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
function ownsPick(pickedId) {
return parts.router.ownsPick(pickedId);
}
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
}
/**
* Esc clears the selected sequence, but only once nothing closer to the
* user wants it: an expanded viewer, an open panel or a text field.
*/
function onKeyDown(event) {
if (event.key !== 'Escape' || event.defaultPrevented) return;
// A text field keeps its Esc, rich-text editors (contenteditable) included.
const target = event.target;
if (
target?.isContentEditable === true ||
target?.closest?.(
'.panel-collapsible, [role="dialog"], input, textarea, select',
)
)
return;
if (!parts.hasSelectedSequence()) return;
event.preventDefault();
parts.clearSequences();
}
let hoverQueued = false;
let hoverCursor = false;
let hoverPosition = null;
let hoverLastPickAt = -Infinity;
let hoverTimer = null;
let pointerButtons = 0;
let removeHoverWatchers = null;
function setHoverCursor(canvas, hit) {
if (hit && !hoverCursor) {
canvas.style.cursor = 'pointer';
hoverCursor = true;
} else if (!hit && hoverCursor) {
canvas.style.cursor = '';
hoverCursor = false;
}
}
function pickHover() {
hoverQueued = false;
const viewer = state.viewer;
// Switched off (or torn down) since the frame was queued.
if (!viewer || !state.enabled || !state.clickHandler) return;
// A drag in progress: the pointer is the camera's.
if (pointerButtons !== 0) return;
const canvas = viewer.scene?.canvas;
if (!canvas || viewer.isDestroyed?.()) return;
hoverLastPickAt = Date.now();
let hit = false;
try {
const picked = viewer.scene.pick(hoverPosition);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
hit = ownsPick(id);
} catch {
hit = false;
}
setHoverCursor(canvas, hit);
}
/**
* Pointer cursor over anything this layer owns. Throttled; a move inside
* the interval is picked when it ends, so the cursor never sticks.
*/
function onMove(movement) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
hoverPosition = movement.endPosition;
queueHoverPick();
}
/** Pick `hoverPosition` on a coming frame, within the throttle. */
function queueHoverPick() {
if (hoverQueued) return;
hoverQueued = true;
const wait = hoverLastPickAt + HOVER_PICK_INTERVAL_MS - Date.now();
if (wait <= 0) {
requestAnimationFrame(pickHover);
return;
}
hoverTimer = setTimeout(() => {
hoverTimer = null;
requestAnimationFrame(pickHover);
}, wait);
}
/**
* Track held buttons (a drag picks nothing) and re-pick when the camera
* rests. Hover keeps running while it moves: a tracking camera never stops.
*/
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
const camera = viewer.camera;
const onButtons = (event) => {
pointerButtons = event.buttons ?? 0;
};
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.addEventListener?.(type, onButtons);
const removeEnd = camera?.moveEnd?.addEventListener(() => {
if (hoverPosition) queueHoverPick();
});
removeHoverWatchers = () => {
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.removeEventListener?.(type, onButtons);
removeEnd?.();
};
}
function unwatchHover() {
removeHoverWatchers?.();
removeHoverWatchers = null;
clearTimeout(hoverTimer);
hoverTimer = null;
hoverQueued = false;
hoverPosition = null;
hoverLastPickAt = -Infinity;
pointerButtons = 0;
}
function install(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state.clickHandler.setInputAction(
onClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
state.clickHandler.setInputAction(
onMove,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
watchHover(viewer);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
}
function uninstall() {
// Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return;
unwatchHover();
if (hoverCursor && state.viewer?.scene?.canvas) {
state.viewer.scene.canvas.style.cursor = '';
hoverCursor = false;
}
if (state.clickHandler && !state.clickHandler.isDestroyed())
state.clickHandler.destroy();
state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
}
return { install, uninstall, ownsPick };
}
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import * as Cesium from 'cesium';
import { createSelection } from './selection.js';
/**
* The selection installed on a stand-in viewer. Animation frames queue until
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
* has a known timeline.
*/
function harness({ selected = false } = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task);
globalThis.document = new EventTarget();
// The document's keydown listeners, so a test can hand them any target.
const keyListeners = new Set();
const addListener = globalThis.document.addEventListener.bind(
globalThis.document,
);
const removeListener = globalThis.document.removeEventListener.bind(
globalThis.document,
);
globalThis.document.addEventListener = (type, listener, options) => {
if (type === 'keydown') keyListeners.add(listener);
addListener(type, listener, options);
};
globalThis.document.removeEventListener = (type, listener, options) => {
if (type === 'keydown') keyListeners.delete(listener);
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null };
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() };
const viewer = {
scene: {
canvas,
pick(position) {
picks.push(position);
return scene.under && { id: scene.under };
},
},
camera,
isDestroyed: () => false,
};
const state = {
viewer,
enabled: true,
clickHandler: null,
services: { picking: null, input: null },
};
const selection = createSelection({
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
clearSequences() {
sequences.cleared++;
sequences.selected = false;
},
},
});
selection.install(viewer);
const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
return {
state,
sequences,
/** A keydown reaching the document from `target`; the event as handled. */
key(key, target, { defaultPrevented = false } = {}) {
const event = {
key,
target,
defaultPrevented,
preventDefault() {
this.defaultPrevented = true;
},
};
for (const listener of [...keyListeners]) listener(event);
return event;
},
scene,
canvas,
camera,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
frame() {
for (const task of frames.splice(0)) task();
},
/** Let `ms` pass, running the frames that come due on the way. */
wait(ms) {
this.frame();
mock.timers.tick(ms);
this.frame();
},
buttons(type, buttons) {
const event = new Event(type);
Object.defineProperty(event, 'buttons', { value: buttons });
canvas.dispatchEvent(event);
},
restore() {
selection.uninstall();
mock.timers.reset();
Object.assign(globalThis, saved);
},
};
}
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => {
const h = harness();
try {
// One second of a pointer moving at 60 Hz.
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
h.wait(200);
assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position');
const settled = h.picks.length;
h.wait(1000);
assert.equal(h.picks.length, settled, 'a still pointer costs nothing');
} finally {
h.restore();
}
});
test('hover does not pick while a mouse button is held', () => {
const h = harness();
try {
h.buttons('pointerdown', 1);
h.move(1);
h.wait(200);
assert.equal(h.picks.length, 0, 'a drag in progress');
h.buttons('pointerup', 0);
h.move(2);
h.wait(200);
assert.equal(h.picks.length, 1);
} finally {
h.restore();
}
});
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
const h = harness();
try {
// An orbiting or tracking camera raises moveStart and never moveEnd.
h.camera.moveStart.raiseEvent();
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
} finally {
h.restore();
}
});
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
h.move(5);
h.wait(200);
assert.equal(h.canvas.style.cursor, 'pointer', 'over a line');
// The camera moves the line out from under a still pointer.
h.camera.moveStart.raiseEvent();
h.scene.under = null;
h.camera.moveEnd.raiseEvent();
h.wait(200);
assert.equal(h.picks.length, 2, 'one pick when the camera rests');
assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 });
assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone');
} finally {
h.restore();
}
});
test('a hover frame queued before the layer went off does not pick', () => {
const h = harness();
try {
h.move(1);
h.state.enabled = false;
h.wait(200);
assert.equal(h.picks.length, 0);
} finally {
h.restore();
}
});
/**
* An element as Esc sees it: `closest` matches tag names in a selector list;
* `isContentEditable` is inherited from an editing host, as in a browser.
*/
function element(tag, { isContentEditable = false } = {}) {
return {
tagName: tag.toUpperCase(),
isContentEditable,
closest(selector) {
const names = selector.split(',').map((part) => part.trim());
return names.includes(tag) ? this : null;
},
};
}
test('Esc on the globe clears the selected sequence', () => {
const h = harness({ selected: true });
try {
const event = h.key('Escape', element('canvas'));
assert.equal(h.sequences.cleared, 1);
assert.equal(event.defaultPrevented, true, 'and claims the key');
h.key('Escape', element('canvas'));
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
} finally {
h.restore();
}
});
test('an Esc something else already handled keeps the selection (M60)', () => {
const h = harness({ selected: true });
try {
h.key('Escape', element('canvas'), { defaultPrevented: true });
assert.equal(h.sequences.cleared, 0);
} finally {
h.restore();
}
});
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
const h = harness({ selected: true });
try {
for (const tag of ['input', 'textarea', 'select']) {
const event = h.key('Escape', element(tag));
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
}
// A <div contenteditable> (or anything inside one) is a text field too.
h.key('Escape', element('div', { isContentEditable: true }));
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
} finally {
h.restore();
}
});
+59
View File
@@ -0,0 +1,59 @@
import { FILTER_DEFAULT } from './policy.js';
/**
* The per-image fields of `state.street` with no image open; closing resets
* exactly these (host, render mode and follow availability outlive an image).
*/
export function freshStreet() {
return {
open: false,
follow: false,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
tilt: null,
altitude: null,
isPano: false,
capturedAt: null,
sequenceId: null,
creator: null,
externalUrl: null,
loading: false,
error: null,
};
}
/** Mutable core state, created once per layer instance. */
export function createState({ services }) {
return {
services,
viewer: null,
enabled: false,
initialized: false,
destroyed: false,
listeners: new Set(),
notify: null,
/** Shared imagery filter, in the stored (relative-days) form. */
filter: { ...FILTER_DEFAULT },
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
providers: new Map(),
street: {
host: null,
/** Whether the active map stack allows following (Google 3D only). */
followAvailable: false,
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
renderMode: 'letterbox',
...freshStreet(),
},
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
clickHandler: null,
};
}
+206
View File
@@ -0,0 +1,206 @@
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
const MAX_LAT = 85.05112878;
function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
/** Fractional tile column of a longitude in [-180, 180] at zoom z. */
function tileXAt(lon, z) {
return ((lon + 180) / 360) * 2 ** z;
}
/** Fractional tile row of a latitude at zoom z. */
function tileYAt(lat, z) {
const rad = (clampLat(lat) * Math.PI) / 180;
return (
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
);
}
export function lonToTileX(lon, z) {
const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first.
if (lon >= 180) return n - 1;
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
}
export function latToTileY(lat, z) {
const n = 2 ** z;
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
}
/** West longitude of tile column x at zoom z. */
function tileXToLon(x, z) {
return (x / 2 ** z) * 360 - 180;
}
/** North latitude of tile row y at zoom z. */
function tileYToLat(y, z) {
const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
east: tileXToLon(x + 1, z),
north: tileYToLat(y, z),
south: tileYToLat(y + 1, z),
};
}
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
const south = Math.max(-MAX_LAT, Math.min(s, n));
const north = Math.min(MAX_LAT, Math.max(s, n));
if (east - west <= 0 || north - south <= 0) return null;
return { west, south, east, north };
}
/**
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
* `from` → `to` at zoom z, measured the short way round the date line.
*/
function segmentDistance2(tile, from, to, z) {
const n = 2 ** z;
const px = tile.x + 0.5;
const py = tile.y + 0.5;
const ax = tileXAt(from.lon, z);
const ay = tileYAt(from.lat, z);
let bx = to ? tileXAt(to.lon, z) : ax;
const by = to ? tileYAt(to.lat, z) : ay;
if (bx - ax > n / 2) bx -= n;
else if (ax - bx > n / 2) bx += n;
let best = Infinity;
for (const shift of [-n, 0, n]) {
const x = px + shift;
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy;
const t = len2
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
: 0;
const dx = x - (ax + t * vx);
const dy = py - (ay + t * vy);
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
/**
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const n = 2 ** z;
const tiles = [];
let centre;
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
// A box across the date line comes as west > east: split it at ±180°.
const [west, south, east, north] = bbox.map(Number);
for (const half of [
[west, south, 180, north],
[-180, south, east, north],
]) {
const box = normalizeBbox(half);
if (box) tiles.push(...tileGrid(box, z).tiles);
}
centre = {
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
};
} else {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const grid = tileGrid(box, z);
tiles.push(...grid.tiles);
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
}
const distance2 = focus?.from
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
: (tile) => {
// Measured the short way round the date line.
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
.map((tile) => ({ tile, d: distance2(tile) }))
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
return {
tiles: truncated ? ranked.slice(0, limit) : ranked,
truncated,
total: ranked.length,
};
}
/** The tiles at zoom z in a normalized box, row by row, and their range. */
function tileGrid(box, z) {
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
return { tiles, x0, x1, y0, y1 };
}
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n;
const lat =
(180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc)));
return [lon, lat];
}
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
if (heightM > 12_000) return 11;
if (heightM > 5_000) return 12;
if (heightM > 1_800) return 13;
return 14;
}
/**
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more