mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/cctv-hls-insecure-context
This commit is contained in:
commit
c34cef559d
334 files changed
+59353
-2009
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/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 scenePick from '../../data/scenePick.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
|
||||
/** The browser Maps key the scene was started with (src/app/scene.js). */
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/**
|
||||
* Mapillary, Google Street View and KartaView, once the Mapillary source is
|
||||
* supplied; else none.
|
||||
*/
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
return [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
createKartaViewProvider(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, true);
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
|
||||
// KartaView is keyless and free, but one source shows at a time.
|
||||
layer.setParams({ providerSwitches: 'kartaview-1' });
|
||||
assert.equal(layer.getParams().kartaview, true);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadNycSourcesFromOpenData,
|
||||
nycCameraIsOnline,
|
||||
nycCameraName,
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_IMAGE_ORIGIN,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyNycCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('['));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
|
||||
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
id: MIDTOWN_ID,
|
||||
name: 'Central Park West @ 86 St',
|
||||
latitude: 40.785302,
|
||||
longitude: -73.969353,
|
||||
area: 'Manhattan',
|
||||
isOnline: 'true',
|
||||
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = nycCameraToSource(row());
|
||||
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(source.name, 'Central Park West @ 86 St');
|
||||
assert.equal(source.city, 'Manhattan');
|
||||
assert.equal(source.cityId, 'nyc');
|
||||
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
|
||||
assert.equal(source.lat, 40.785302);
|
||||
assert.equal(source.lon, -73.969353);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'nyc-dot-tmc');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Public NYC DOT Traffic Management Center traffic camera',
|
||||
);
|
||||
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
|
||||
const source = nycCameraToSource(
|
||||
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
|
||||
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ isOnline: true })).id,
|
||||
`nyc-${MIDTOWN_ID}`,
|
||||
);
|
||||
// Austin: a plausible lat/lon, but not a New York camera.
|
||||
assert.equal(
|
||||
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
|
||||
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
|
||||
assert.equal(nycCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
|
||||
const mixed = nycCameraToSource(
|
||||
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
|
||||
);
|
||||
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
|
||||
assert.equal(mixed.city, 'New York');
|
||||
assert.equal(
|
||||
mixed.url,
|
||||
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
|
||||
'the registered frame path keeps the catalog UUID spelling',
|
||||
);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
|
||||
'FDR Drive @ 23 St',
|
||||
);
|
||||
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
|
||||
assert.equal(nycCameraIsOnline('true'), true);
|
||||
assert.equal(nycCameraIsOnline('false'), false);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
|
||||
const long = nycCameraToSource(
|
||||
row({
|
||||
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BROOKLYN'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
name: 'Central Park West @ 86 St (duplicate)',
|
||||
latitude: 40.758,
|
||||
longitude: -73.9855,
|
||||
}),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
|
||||
}),
|
||||
row({ isOnline: 'false', name: 'Offline camera' }),
|
||||
row({ id: 'https://evil.example/x' }),
|
||||
]);
|
||||
});
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
|
||||
assert.equal(cameras[1].city, 'Brooklyn');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify([row()]);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_NYC_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
|
||||
else process.env.CCTV_NYC_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_NYC_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
// Far southern Staten Island, listed first so only distance can keep them.
|
||||
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
|
||||
row({
|
||||
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -74.24,
|
||||
area: 'Staten Island',
|
||||
name: `SI ${i}`,
|
||||
}),
|
||||
),
|
||||
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
|
||||
row({
|
||||
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
|
||||
latitude: lat,
|
||||
longitude: -73.9855,
|
||||
area: 'Manhattan',
|
||||
name: `Midtown ${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadNycSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.city),
|
||||
[
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Manhattan',
|
||||
'Staten Island',
|
||||
'Staten Island',
|
||||
],
|
||||
'Midtown fills first; the Staten Island anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
|
||||
* other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === NYC_CAMERAS_URL) {
|
||||
return Response.json([
|
||||
row({ latitude: 40.758, longitude: -73.9855 }),
|
||||
row({
|
||||
id: BROOKLYN_ID,
|
||||
name: 'Atlantic Ave @ Flatbush Ave',
|
||||
latitude: 40.65,
|
||||
longitude: -73.96,
|
||||
area: 'Brooklyn',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
|
||||
* leaves it unset), restoring the environment afterwards. */
|
||||
const runCatalogWithNycFlag = async (t, flag) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
|
||||
else process.env.CCTV_NYC_ENABLED = flag;
|
||||
return await runCatalogWithMockedUpstreams(t);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
|
||||
assert.ok(
|
||||
requested.includes(NYC_CAMERAS_URL),
|
||||
'the catalog refresh invokes the NYC loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
|
||||
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
|
||||
'NYC cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
});
|
||||
|
||||
// NYC DOT requires a data-sharing agreement for this feed, so an install that
|
||||
// has not opted in must never contact it.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_NYC_ENABLED', undefined],
|
||||
['an empty CCTV_NYC_ENABLED', ''],
|
||||
['CCTV_NYC_ENABLED=0', '0'],
|
||||
['CCTV_NYC_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no NYC request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('nyctmc.org')),
|
||||
false,
|
||||
'the lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'nyc'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
|
||||
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
|
||||
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
|
||||
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
|
||||
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
|
||||
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyNycCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
|
||||
});
|
||||
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** NYC DOT asks every application that uses its data to carry this
|
||||
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
|
||||
export const NYC_DOT_DISCLAIMER =
|
||||
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
|
||||
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
|
||||
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
|
||||
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
@@ -247,6 +254,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New York City, when enabled): ' +
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA_CREDITS } from './dataCredits.js';
|
||||
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
|
||||
|
||||
test('every credit carries a unique key and some markup to render', () => {
|
||||
const keys = DATA_CREDITS.map((entry) => entry.key);
|
||||
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
|
||||
assert.equal(a.credits.size, 0);
|
||||
assert.equal(b.credits.size, 1);
|
||||
});
|
||||
|
||||
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
|
||||
assert.ok(credit, 'NYC DOT camera frames must be credited');
|
||||
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+178
-27
@@ -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,141 @@ 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 switches keeps Mapillary on and Street View off', () => {
|
||||
assert.deepEqual(streetLevelOptions(''), {
|
||||
mapillary: true,
|
||||
providerSwitches: '',
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
// Street View rides the shared provider-switch field, off unless lit.
|
||||
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
|
||||
assert.equal(
|
||||
createDefaultLayerState().options['street-level'].providerSwitches,
|
||||
'',
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* The world position under a screen point: the rendered surface where the
|
||||
* depth buffer has it, else the ellipsoid; null over the sky.
|
||||
*
|
||||
* @param {object} viewer A Cesium viewer.
|
||||
* @param {{x: number, y: number}} screenPosition
|
||||
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
|
||||
*/
|
||||
export function pickGroundPosition(viewer, screenPosition) {
|
||||
const scene = viewer?.scene;
|
||||
let position = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
position = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(position) &&
|
||||
typeof viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
position = viewer.camera.pickEllipsoid(screenPosition);
|
||||
} catch {
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(position) ? position : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
|
||||
* content (which has no id).
|
||||
*
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSurfacePick(picked) {
|
||||
if (!picked) return true;
|
||||
if (picked.id !== undefined) return false;
|
||||
return (
|
||||
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
|
||||
);
|
||||
}
|
||||
+68
-13
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from './scenePick.js';
|
||||
|
||||
const describe = (position) => (position
|
||||
? `(${position.x}, ${position.y}, ${position.z})`
|
||||
: String(position));
|
||||
const describe = (position) =>
|
||||
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
|
||||
|
||||
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
// These are the shapes `scene.pickPosition()` can produce when the depth read
|
||||
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
|
||||
new Cesium.Cartesian3(0.2, -0.3, 0.1),
|
||||
];
|
||||
for (const position of degenerate) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(0, 0, 5_999_999),
|
||||
];
|
||||
for (const position of belowTheFloor) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
|
||||
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
|
||||
assert.ok(
|
||||
carto.height < -300_000,
|
||||
'and puts the "target" hundreds of km underground',
|
||||
);
|
||||
}
|
||||
|
||||
const aboveTheCeiling = [
|
||||
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
new Cesium.Cartesian3(1e155, 0, 0),
|
||||
];
|
||||
for (const position of aboveTheCeiling) {
|
||||
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
false,
|
||||
`must reject ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
|
||||
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
|
||||
);
|
||||
|
||||
// Both edges of the band are inclusive, and both are real magnitudes.
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
|
||||
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a real picked position is accepted', () => {
|
||||
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
];
|
||||
for (const position of real) {
|
||||
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
|
||||
assert.equal(
|
||||
isPickedWorldPosition(position),
|
||||
true,
|
||||
`must accept ${describe(position)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The floor has real margin under the smallest legitimate magnitude, so no
|
||||
// plausible pick sits anywhere near the boundary.
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
|
||||
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
|
||||
const polarSurface = Cesium.Cartesian3.magnitude(
|
||||
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
|
||||
);
|
||||
assert.ok(
|
||||
polarSurface > 6_300_000,
|
||||
`smallest real magnitude was ${polarSurface}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
// Earth's core. That is the quietest failure of the three and the reason
|
||||
// the guard needs a magnitude band, not just a finiteness check.
|
||||
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
|
||||
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
|
||||
assert.ok(
|
||||
Cesium.Cartographic.fromCartesian(nearCenter),
|
||||
'Cesium converts it without complaint',
|
||||
);
|
||||
assert.equal(isPickedWorldPosition(nearCenter), false);
|
||||
|
||||
// …and every position the guard accepts must convert to the right place.
|
||||
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
|
||||
});
|
||||
|
||||
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
|
||||
const { pickGroundPosition } = await import('./scenePick.js');
|
||||
const ground = { x: 6378137, y: 0, z: 0 };
|
||||
const sky = { x: 500, y: 0, z: 0 };
|
||||
const viewer = (picked, ellipsoid) => ({
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: { pickEllipsoid: () => ellipsoid },
|
||||
});
|
||||
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
|
||||
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
|
||||
assert.equal(pickGroundPosition(null, {}), null);
|
||||
});
|
||||
|
||||
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
|
||||
const { isSurfacePick } = await import('./scenePick.js');
|
||||
assert.equal(isSurfacePick(undefined), true);
|
||||
assert.equal(isSurfacePick({ content: {} }), true);
|
||||
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
|
||||
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
|
||||
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
+35
-3
@@ -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.
|
||||
*/
|
||||
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
unlocks: 'The photorealistic 3D planet + place search',
|
||||
unlocks: 'The photorealistic 3D planet, place search + Street View',
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
@@ -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',
|
||||
@@ -342,6 +352,28 @@ export function keySetupRequirement(id) {
|
||||
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key guidance that also names switched-off sources whose key is already set.
|
||||
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
*/
|
||||
export function keySetupWithAlternatives(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const entry = KEY_SETUP_KEYS.find(
|
||||
(candidate) => candidate.id === requiresKeyId,
|
||||
);
|
||||
const name = entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = `or ${options.join(', or ')}`;
|
||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
knownKeySetupEnvVars,
|
||||
parseWindowsUserSid,
|
||||
upsertDotenvValues,
|
||||
@@ -362,3 +363,16 @@ test('server Google key remains supported without appearing in setup or its miss
|
||||
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
|
||||
assert.ok(!JSON.stringify(status).includes(secret));
|
||||
});
|
||||
|
||||
test('key guidance can offer another source that works with a key already set', () => {
|
||||
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
|
||||
assert.equal(
|
||||
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(
|
||||
keySetupWithAlternatives('', [streetView]),
|
||||
'Light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Vendored
+50
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+33
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -618,32 +617,11 @@ export function createDirectionsLayer({ services }) {
|
||||
}
|
||||
|
||||
function pickGround(screenPosition) {
|
||||
const scene = _viewer?.scene;
|
||||
let cartesian = null;
|
||||
if (
|
||||
scene?.pickPositionSupported &&
|
||||
typeof scene.pickPosition === 'function'
|
||||
) {
|
||||
try {
|
||||
cartesian = scene.pickPosition(screenPosition);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!services.scenePick.isPickedWorldPosition(cartesian) &&
|
||||
typeof _viewer?.camera?.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
cartesian = _viewer.camera.pickEllipsoid(
|
||||
screenPosition,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
} catch {
|
||||
cartesian = null;
|
||||
}
|
||||
}
|
||||
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
|
||||
const cartesian = services.scenePick.pickGroundPosition(
|
||||
_viewer,
|
||||
screenPosition,
|
||||
);
|
||||
if (!cartesian) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
@@ -827,18 +805,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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+29
@@ -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 =
|
||||
|
||||
Vendored
+34
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
|
||||
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
|
||||
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
|
||||
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
|
||||
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
|
||||
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, 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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
|
||||
import { cameraHeightAboveGround } from './view.js';
|
||||
|
||||
/** Whether the camera is low enough for a click on the ground to mean a street. */
|
||||
export function groundClickHeightOk(viewer, groundAt) {
|
||||
if (!viewer?.camera?.positionCartographic) return false;
|
||||
return (
|
||||
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on the map itself at street zoom opens the nearest image of the
|
||||
* active `groundClick` providers, without moving the camera. The height is
|
||||
* measured at the click, not read from the cached flag.
|
||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
|
||||
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||
*/
|
||||
export function createGroundClick({
|
||||
state,
|
||||
openNearest,
|
||||
isAvailable,
|
||||
groundAt = null,
|
||||
}) {
|
||||
return function openAtGround(screenPosition, picked) {
|
||||
const scenePick = state.services.scenePick;
|
||||
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
||||
return false;
|
||||
if (
|
||||
!scenePick.isSurfacePick(picked) ||
|
||||
!groundClickHeightOk(state.viewer, groundAt)
|
||||
)
|
||||
return false;
|
||||
const providerIds = [...state.providers.values()]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.on &&
|
||||
entry.def.groundClick &&
|
||||
entry.status?.configured &&
|
||||
isAvailable(entry),
|
||||
)
|
||||
.map((entry) => entry.def.id);
|
||||
if (!providerIds.length) return false;
|
||||
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
|
||||
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||
if (!carto) return false;
|
||||
return openNearest(
|
||||
{
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
},
|
||||
{ providerIds, frame: false },
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import * as scenePick from '../../data/scenePick.js';
|
||||
|
||||
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
|
||||
const TILESET = { isCesium3DTileset: true };
|
||||
|
||||
/**
|
||||
* A layer state with providers `{id, on, groundClick, configured, available}`;
|
||||
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
|
||||
*/
|
||||
function harness({
|
||||
providers = [
|
||||
{ id: 'mapillary', groundClick: false },
|
||||
{ id: 'google', groundClick: true },
|
||||
],
|
||||
height = 400,
|
||||
enabled = true,
|
||||
picked = GROUND,
|
||||
ellipsoid = undefined,
|
||||
} = {}) {
|
||||
const opened = [];
|
||||
const state = {
|
||||
enabled,
|
||||
services: { scenePick },
|
||||
providers: new Map(
|
||||
providers.map(
|
||||
({
|
||||
id,
|
||||
on = true,
|
||||
groundClick,
|
||||
configured = true,
|
||||
available = true,
|
||||
}) => [
|
||||
id,
|
||||
{ on, def: { id, groundClick }, status: { configured }, available },
|
||||
],
|
||||
),
|
||||
),
|
||||
viewer: {
|
||||
scene: { pickPositionSupported: true, pickPosition: () => picked },
|
||||
camera: {
|
||||
pickEllipsoid: () => ellipsoid,
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(
|
||||
-121.4944,
|
||||
38.5816,
|
||||
height,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
const openAtGround = createGroundClick({
|
||||
state,
|
||||
openNearest: (point, options) => {
|
||||
opened.push({ point, options });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
isAvailable: (entry) => entry.available,
|
||||
});
|
||||
return { opened, openAtGround };
|
||||
}
|
||||
|
||||
const near = (point) =>
|
||||
Math.abs(point.lon - -121.4944) < 1e-9 &&
|
||||
Math.abs(point.lat - 38.5816) < 1e-9;
|
||||
|
||||
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
|
||||
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
|
||||
const { opened, openAtGround } = harness();
|
||||
openAtGround({ x: 10, y: 20 }, picked);
|
||||
assert.equal(opened.length, 1, JSON.stringify(picked));
|
||||
assert.ok(near(opened[0].point));
|
||||
assert.deepEqual(opened[0].options, {
|
||||
providerIds: ['google'],
|
||||
frame: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('where the depth buffer names no place, the ellipsoid answers', () => {
|
||||
const { opened, openAtGround } = harness({
|
||||
picked: new Cesium.Cartesian3(500, 0, 0),
|
||||
ellipsoid: GROUND,
|
||||
});
|
||||
openAtGround({ x: 1, y: 1 });
|
||||
assert.ok(near(opened[0].point));
|
||||
});
|
||||
|
||||
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
|
||||
const cases = [
|
||||
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
|
||||
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
|
||||
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
|
||||
{ options: { height: 5000 }, label: 'high up' },
|
||||
{
|
||||
options: {
|
||||
providers: [{ id: 'google', groundClick: true, available: false }],
|
||||
},
|
||||
label: 'not Google 3D',
|
||||
},
|
||||
{
|
||||
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
|
||||
label: 'off',
|
||||
},
|
||||
{
|
||||
options: {
|
||||
providers: [{ id: 'google', groundClick: true, configured: false }],
|
||||
},
|
||||
label: 'no key',
|
||||
},
|
||||
{ options: { enabled: false }, label: 'layer off' },
|
||||
];
|
||||
for (const { picked, options = {}, label } of cases) {
|
||||
const { opened, openAtGround } = harness(options);
|
||||
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
|
||||
assert.equal(opened.length, 0, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('street zoom is measured above the ground under the camera', () => {
|
||||
const viewer = (height) => ({
|
||||
camera: {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
|
||||
},
|
||||
scene: { globe: { getHeight: () => 1500 } },
|
||||
});
|
||||
// 1,500 m up a plateau, 2,000 m above it: still a street.
|
||||
assert.equal(groundClickHeightOk(viewer(3500)), true);
|
||||
assert.equal(groundClickHeightOk(viewer(5000)), false);
|
||||
assert.equal(groundClickHeightOk(null), false);
|
||||
});
|
||||
@@ -0,0 +1,766 @@
|
||||
import * as Cesium from 'cesium';
|
||||
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 { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import {
|
||||
composeUIState,
|
||||
keyAlternatives,
|
||||
summarizeCoverage,
|
||||
} from './uiState.js';
|
||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.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 {
|
||||
AIM_AT_GROUND_MESSAGE,
|
||||
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);
|
||||
parts.openAtGround = createGroundClick({
|
||||
state,
|
||||
openNearest: (point, options) => layer.openNearest(point, options),
|
||||
isAvailable: (entry) => providerAvailable(entry),
|
||||
groundAt: parts.groundCaster?.groundAt ?? null,
|
||||
});
|
||||
|
||||
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: {
|
||||
/** `{frame: false}` opens it without moving the camera. */
|
||||
openImage: (imageId, options) =>
|
||||
openImage(entry.def.id, imageId, options),
|
||||
/** This provider's image nearest a point the user picked on the map. */
|
||||
openNearest: (point) =>
|
||||
layer.openNearest(point, {
|
||||
providerIds: [entry.def.id],
|
||||
frame: false,
|
||||
}),
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
|
||||
for (const def of definitionsFrozen) {
|
||||
const entry = {
|
||||
def,
|
||||
instance: null,
|
||||
on: def === startsOn,
|
||||
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();
|
||||
}
|
||||
|
||||
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
||||
function providerAvailable(entry) {
|
||||
const required = entry.def.requiresMapStack;
|
||||
return !required || mapStack?.getActiveId?.() === required;
|
||||
}
|
||||
|
||||
/** Leaving a provider's map stack closes its image, viewer and credit. */
|
||||
function syncProviderAvailability() {
|
||||
const owner = state.providers.get(state.street.providerId);
|
||||
if (state.street.open && owner && !providerAvailable(owner))
|
||||
layer.closeViewer();
|
||||
for (const entry of state.providers.values()) {
|
||||
if (!entry.def.requiresMapStack) continue;
|
||||
if (state.enabled && entry.on && providerAvailable(entry))
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
else {
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
parts.viewerHost.unmount(entry.def.id);
|
||||
}
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Whether a ground click can open an image: the camera is at street zoom. */
|
||||
function syncGroundClickReady() {
|
||||
const ready =
|
||||
state.enabled &&
|
||||
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
|
||||
if (ready === state.groundClickReady) return;
|
||||
state.groundClickReady = ready;
|
||||
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() {
|
||||
syncGroundClickReady();
|
||||
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);
|
||||
if (!entry.status) refreshStatus(entry);
|
||||
if (!providerAvailable(entry)) return;
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
// The map may have changed by then.
|
||||
whenIdle(() => {
|
||||
if (providerAvailable(entry)) 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;
|
||||
}
|
||||
|
||||
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const pick = state.services.scenePick?.pickGroundPosition;
|
||||
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
|
||||
return undefined;
|
||||
const position = pick(state.viewer, {
|
||||
x: canvas.clientWidth / 2,
|
||||
y: canvas.clientHeight / 2,
|
||||
});
|
||||
const carto = position && Cesium.Cartographic.fromCartesian(position);
|
||||
return carto
|
||||
? {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** `frame: false` keeps the camera where it is (the user picked the spot). */
|
||||
function openImage(providerId, imageId, { frame = true } = {}) {
|
||||
abortNearest();
|
||||
const entry = state.providers.get(providerId);
|
||||
if (entry && !providerAvailable(entry)) {
|
||||
state.street.loading = false;
|
||||
state.street.error = unavailableReason(entry);
|
||||
notify();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return parts.viewerHost.open(providerId, imageId, { frame });
|
||||
}
|
||||
|
||||
function unavailableReason(entry) {
|
||||
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
|
||||
}
|
||||
|
||||
/** One imagery source at a time: lighting a provider darkens the others. */
|
||||
function setProviderEnabled(providerId, on) {
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry) return false;
|
||||
const before = litIds();
|
||||
const next = on !== false;
|
||||
if (next)
|
||||
for (const other of state.providers.values())
|
||||
if (other !== entry && other.on) switchProvider(other, false);
|
||||
switchProvider(entry, next);
|
||||
sourceChanged(before);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
const litIds = () =>
|
||||
[...state.providers]
|
||||
.filter(([, entry]) => entry.on)
|
||||
.map(([id]) => id)
|
||||
.join();
|
||||
|
||||
/**
|
||||
* After a chip, voice or a link changed the lit source: a lookup still
|
||||
* running and its spinner or error were about the source switched away
|
||||
* (an open photo keeps its own state).
|
||||
*/
|
||||
function sourceChanged(before) {
|
||||
if (litIds() === before) return;
|
||||
abortNearest();
|
||||
if (state.street.open) return;
|
||||
state.street.error = null;
|
||||
state.street.loading = false;
|
||||
}
|
||||
|
||||
function switchProvider(entry, on) {
|
||||
if (entry.on === on) return;
|
||||
entry.on = on;
|
||||
if (!state.enabled) return;
|
||||
if (on) activate(entry);
|
||||
else deactivate(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one provider params leave lit: of those they switch on, the one
|
||||
* already on (reapplying never flips), else the first; with none, the lit
|
||||
* one unless they switch it off.
|
||||
*/
|
||||
function litByParams(switches) {
|
||||
const asked = [...state.providers.keys()].filter(
|
||||
(id) => switches.get(id) === true,
|
||||
);
|
||||
const current = [...state.providers]
|
||||
.filter(([, entry]) => entry.on)
|
||||
.map(([id]) => id);
|
||||
if (asked.length)
|
||||
return asked.find((id) => current.includes(id)) ?? asked[0];
|
||||
return current.find((id) => switches.get(id) !== false) ?? null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
return `zoom in to a street to open ${entry.def.name}`;
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
/** Providers usable on this map stack; the others are not shown at all. */
|
||||
const listedEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => providerAvailable(entry));
|
||||
|
||||
function providerSnapshots() {
|
||||
return listedEntries().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: hintFor(entry, stats),
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const ui = composeUIState({
|
||||
enabled: state.enabled,
|
||||
groundClickReady: state.groundClickReady,
|
||||
filter: state.filter,
|
||||
providers: providerSnapshots(),
|
||||
street,
|
||||
sequence: sequenceSnapshot(),
|
||||
surface: state.surface,
|
||||
});
|
||||
// The lit source cannot run on this map: say so, not a blank panel.
|
||||
const stranded = [...state.providers.values()].find(
|
||||
(entry) => entry.on && !providerAvailable(entry),
|
||||
);
|
||||
if (stranded && !ui.providers.some((p) => p.on))
|
||||
ui.coverage.hint = unavailableReason(stranded);
|
||||
return ui;
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: STREET_LEVEL_LAYER_ID,
|
||||
name: 'Street Level',
|
||||
icon: '📷',
|
||||
/** The sources usable on this map (Street View only on Google 3D). */
|
||||
get source() {
|
||||
return listedEntries()
|
||||
.map((entry) => entry.def.name)
|
||||
.join(' · ');
|
||||
},
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
/** The key the switched-on providers share (or null), for the layer row. */
|
||||
get requiresKeyId() {
|
||||
const on = [...state.providers.values()].filter((entry) => entry.on);
|
||||
return requiresKeyIdFor(
|
||||
on.length ? on.map((entry) => entry.def) : 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 snapshots = providerSnapshots();
|
||||
const coverage = summarizeCoverage(snapshots);
|
||||
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 {
|
||||
source: layer.source,
|
||||
count: coverage.count,
|
||||
loading: coverage.loading,
|
||||
keyRequired: coverage.keyRequired,
|
||||
keyAlternatives: keyAlternatives(snapshots),
|
||||
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();
|
||||
syncProviderAvailability();
|
||||
syncSurface();
|
||||
}) || null;
|
||||
syncFollowAvailability();
|
||||
syncProviderAvailability();
|
||||
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,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
if (lit) switchProvider(state.providers.get(lit), true);
|
||||
sourceChanged(before);
|
||||
notify();
|
||||
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().filter((entry) => providerAvailable(entry)),
|
||||
),
|
||||
1500,
|
||||
);
|
||||
},
|
||||
setProviderEnabled,
|
||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||
setCoverageFilter,
|
||||
openImage,
|
||||
/**
|
||||
* Open the nearest image of the active providers (or `providerIds`) near
|
||||
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
|
||||
* under the screen centre (sky: nothing).
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||
) {
|
||||
let view = point;
|
||||
if (!view && aim === 'screen-centre') {
|
||||
view = screenCentreGround();
|
||||
if (view === null) {
|
||||
abortNearest();
|
||||
state.street.loading = false;
|
||||
state.street.error = AIM_AT_GROUND_MESSAGE;
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
view ||= 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;
|
||||
const asked = (entry) =>
|
||||
(!providerIds || providerIds.includes(entry.def.id)) &&
|
||||
providerAvailable(entry);
|
||||
const entries = activeEntries().filter(asked);
|
||||
for (const entry of entries) {
|
||||
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;
|
||||
// Switched off, or its map left, while it looked: try the next.
|
||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||
nearestLookup = null;
|
||||
return openImage(entry.def.id, imageId, { frame });
|
||||
}
|
||||
if (nearestLookup === lookup) nearestLookup = null;
|
||||
// Switched off while it looked: nothing to open, nothing to report.
|
||||
const stillOn = entries.some(
|
||||
(entry) => entry.on && providerAvailable(entry),
|
||||
);
|
||||
if (state.enabled && stillOn)
|
||||
state.street.error =
|
||||
lastError?.message ||
|
||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : '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;
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
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 { createKartaViewProvider } from './providers/kartaview/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(options) {
|
||||
const provider = fakeProvider(options);
|
||||
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);
|
||||
});
|
||||
|
||||
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
|
||||
async function withStreetView(
|
||||
t,
|
||||
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
|
||||
) {
|
||||
const { createGoogleProvider } = await import('./providers/google/index.js');
|
||||
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
|
||||
await import('../../testSupport/googleStreetViewFakes.mjs');
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: {
|
||||
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
|
||||
},
|
||||
nearest: nearest ?? (() => 'pano-a'),
|
||||
});
|
||||
const mapillaryLookups = [];
|
||||
const mapillary = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
mapillaryLookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
mapillary.requiresKeyId = 'mapillary';
|
||||
if (mapillaryKeyless) {
|
||||
mapillary.stats.keyRequired = true;
|
||||
const create = mapillary.create;
|
||||
mapillary.create = (context) => ({
|
||||
...create(context),
|
||||
status: async () => ({ configured: false }),
|
||||
});
|
||||
}
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const google = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader,
|
||||
});
|
||||
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
||||
const stack = fakeMapStack(mapStack);
|
||||
layer.attachMapStackController(stack);
|
||||
layer.attachViewerHost(fakeHost());
|
||||
return { layer, viewer, stack, fake, loader, mapillaryLookups };
|
||||
}
|
||||
|
||||
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
const google = () =>
|
||||
layer.getUIState().providers.find((p) => p.id === 'google');
|
||||
assert.equal(google().on, false);
|
||||
assert.equal(layer.requiresKeyId, 'mapillary');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
|
||||
});
|
||||
|
||||
test('the layer starts with one source lit: the first that defaults on', async (t) => {
|
||||
const { layer } = await enabledLayer(t, [
|
||||
fakeProvider(),
|
||||
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id),
|
||||
['mapillary'],
|
||||
);
|
||||
});
|
||||
|
||||
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
|
||||
const { layer, stack, fake } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
let answer;
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.loading, true);
|
||||
stack.switchTo('bing');
|
||||
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
|
||||
assert.equal(await opening, false);
|
||||
const { street } = layer.getUIState();
|
||||
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
|
||||
assert.equal(street.error, null);
|
||||
assert.equal(street.open, false);
|
||||
});
|
||||
|
||||
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
|
||||
let answer;
|
||||
const mapillary = fakeProvider({
|
||||
nearestImage: () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
});
|
||||
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
|
||||
const { layer } = await enabledLayer(t, [mapillary, other]);
|
||||
const opening = layer.openNearest({ lat: 1, lon: 2 });
|
||||
layer.setProviderEnabled('panoramax', true);
|
||||
answer('img-1');
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
assert.equal(layer.getUIState().street.loading, false);
|
||||
});
|
||||
|
||||
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/Google Street View needs the Google 3D map/,
|
||||
);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
|
||||
});
|
||||
|
||||
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
const lit = () =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((p) => p.on)
|
||||
.map((p) => p.id);
|
||||
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
// Params that light one switch to it; reapplying them changes nothing.
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
layer.setParams({ google: true });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
// Params lighting both keep the one already on, however often applied.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
|
||||
assert.deepEqual(lit(), ['google']);
|
||||
}
|
||||
layer.setParams({ mapillary: true, google: false });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Filter-only params leave the source alone.
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(lit(), ['mapillary']);
|
||||
// Darkening the only lit one leaves none: the panel turns the layer off.
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
assert.deepEqual(lit(), []);
|
||||
});
|
||||
|
||||
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
||||
const { layer, viewer, stack } = await withStreetView(t, {
|
||||
mapStack: 'esri-imagery',
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
const ui = () => layer.getUIState();
|
||||
const listed = () => ui().providers.map((p) => p.id);
|
||||
const credited = () =>
|
||||
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
||||
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
|
||||
assert.ok(!ui().legend.some((item) => item.key === 'google'));
|
||||
assert.equal(credited(), false, 'no Google credit on another map');
|
||||
assert.equal(layer.source, 'Mapillary');
|
||||
assert.equal(layer.getStats().source, 'Mapillary');
|
||||
assert.equal(await layer.openImage('google', 'pano-a'), false);
|
||||
assert.match(ui().street.error, /needs the Google 3D map/);
|
||||
assert.equal(layer.getParams().google, true, 'the choice is kept');
|
||||
|
||||
stack.switchTo('photoreal');
|
||||
assert.deepEqual(listed(), ['mapillary', 'google']);
|
||||
assert.equal(credited(), true);
|
||||
assert.equal(layer.source, 'Mapillary · Google Street View');
|
||||
assert.equal(await layer.openImage('google', 'pano-a'), true);
|
||||
assert.equal(ui().street.providerId, 'google');
|
||||
|
||||
stack.switchTo('bing');
|
||||
await settle();
|
||||
assert.equal(ui().street.open, false, 'closed with the map');
|
||||
assert.equal(credited(), false);
|
||||
assert.deepEqual(listed(), ['mapillary']);
|
||||
});
|
||||
|
||||
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
||||
});
|
||||
|
||||
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
|
||||
const { layer, mapillaryLookups } = await withStreetView(t, {
|
||||
nearest: () => null,
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
const opened = await layer.openNearest(
|
||||
{ lat: 38.58, lon: -121.49 },
|
||||
{ providerIds: ['google'], frame: false },
|
||||
);
|
||||
assert.equal(opened, false);
|
||||
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
|
||||
assert.equal(
|
||||
layer.getUIState().street.error,
|
||||
'No street-level imagery within 50 m of that point',
|
||||
);
|
||||
});
|
||||
|
||||
test('an image opened from a ground click leaves the camera where it is', async (t) => {
|
||||
const { layer, viewer } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
await settle();
|
||||
await layer.openNearest(
|
||||
{ lat: 38.58, lon: -121.49 },
|
||||
{ providerIds: ['google'], frame: false },
|
||||
);
|
||||
assert.equal(layer.getUIState().street.imageId, 'pano-a');
|
||||
assert.equal(viewer.flights.started, 0, 'no framing flight');
|
||||
});
|
||||
|
||||
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
|
||||
const { layer, loader } = await withStreetView(t, {
|
||||
mapStack: 'esri-imagery',
|
||||
});
|
||||
layer.setProviderEnabled('google', true);
|
||||
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
|
||||
});
|
||||
|
||||
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
|
||||
const { createStreetLevelLayer: create } = await import('./index.js');
|
||||
const scenePick = await import('../../data/scenePick.js');
|
||||
const lookups = [];
|
||||
const provider = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
lookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
|
||||
Object.assign(viewer.scene, {
|
||||
pickPositionSupported: true,
|
||||
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
|
||||
});
|
||||
viewer.scene.canvas.clientWidth = 1000;
|
||||
viewer.scene.canvas.clientHeight = 600;
|
||||
const layer = create({ providers: [provider], services: { scenePick } });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre' });
|
||||
assert.equal(lookups.length, 1);
|
||||
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
|
||||
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
|
||||
});
|
||||
|
||||
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
|
||||
const { createStreetLevelLayer: create } = await import('./index.js');
|
||||
const scenePick = await import('../../data/scenePick.js');
|
||||
const lookups = [];
|
||||
const provider = fakeProvider({
|
||||
nearestImage: async (point) => {
|
||||
lookups.push(point);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
// Every ground pick misses: the middle of the screen is sky.
|
||||
Object.assign(viewer.scene, {
|
||||
pickPositionSupported: true,
|
||||
pickPosition: () => undefined,
|
||||
});
|
||||
viewer.camera.pickEllipsoid = () => undefined;
|
||||
viewer.scene.canvas.clientWidth = 1000;
|
||||
viewer.scene.canvas.clientHeight = 600;
|
||||
const layer = create({ providers: [provider], services: { scenePick } });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
assert.equal(
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre' }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
|
||||
const { street } = layer.getUIState();
|
||||
assert.match(street.error, /Aim the middle of the view at a street/);
|
||||
assert.equal(street.loading, false);
|
||||
});
|
||||
|
||||
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
|
||||
await settle();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.keyRequired, true);
|
||||
assert.deepEqual(stats.keyAlternatives, [
|
||||
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
]);
|
||||
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
|
||||
// Lit, Street View draws: the layer is no longer key-gated.
|
||||
stack.switchTo('photoreal');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.equal(layer.getStats().keyRequired, false);
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
|
||||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
});
|
||||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||||
const { layer, viewer } = await enabledLayer(t, [failing, other]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openNearest({ lat: 1, lon: 1 });
|
||||
assert.match(layer.getUIState().street.error, /did not answer/);
|
||||
layer.setProviderEnabled('other', true);
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
// A provider's own open (KartaView's ‹ ›) can leave the camera alone.
|
||||
await other.context().actions.openImage('img-1', { frame: false });
|
||||
assert.deepEqual(other.calls.open, ['img-1']);
|
||||
assert.equal(viewer.flights.started, 0);
|
||||
});
|
||||
|
||||
test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => {
|
||||
const provider = posingProvider();
|
||||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||||
layer.attachViewerHost({});
|
||||
await provider.context().actions.openImage('img-1', { frame: false });
|
||||
assert.deepEqual(provider.calls.open, ['img-1']);
|
||||
assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera');
|
||||
await provider.context().actions.openImage('img-2');
|
||||
assert.equal(viewer.flights.started, 1, 'a default open frames the image');
|
||||
});
|
||||
|
||||
test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => {
|
||||
const lit = posingProvider({ nearestImage: async () => 'lit-near' });
|
||||
const other = fakeProvider({
|
||||
id: 'other',
|
||||
pickPrefix: 'oth:',
|
||||
nearestImage: async () => 'other-near',
|
||||
});
|
||||
const { layer, viewer } = await enabledLayer(t, [lit, other]);
|
||||
layer.attachViewerHost({});
|
||||
const point = { lat: 38.58, lon: -121.49 };
|
||||
// The other source is off: its click asks nobody else.
|
||||
await other.context().actions.openNearest(point);
|
||||
await settle();
|
||||
assert.deepEqual(lit.calls.open, [], 'the lit source was not asked');
|
||||
assert.deepEqual(other.calls.open, []);
|
||||
await lit.context().actions.openNearest(point);
|
||||
assert.deepEqual(lit.calls.open, ['lit-near']);
|
||||
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
||||
});
|
||||
|
||||
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
|
||||
const kartaView = createKartaViewProvider({
|
||||
source: {
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => [],
|
||||
getPhoto: async () => {
|
||||
throw new Error('KartaView has no such photo');
|
||||
},
|
||||
getSequencePhotos: async () => ({ photos: [], hasMore: false }),
|
||||
probeImage: async () => true,
|
||||
},
|
||||
});
|
||||
const { layer } = await enabledLayer(t, [kartaView]);
|
||||
layer.setProviderEnabled('kartaview', true);
|
||||
await settle();
|
||||
const ui = layer.getUIState();
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.equal(ui.coverage.label?.noun, 'streets');
|
||||
assert.equal(
|
||||
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
||||
const slow = slowLookups();
|
||||
const looking = fakeProvider({ nearestImage: slow.nearestImage });
|
||||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||||
const { layer } = await enabledLayer(t, [looking, other]);
|
||||
layer.attachViewerHost({});
|
||||
const opening = layer.openNearest({ lat: 1, lon: 1 });
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.loading, true);
|
||||
layer.setParams({ mapillary: false, other: true });
|
||||
assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped');
|
||||
assert.equal(
|
||||
layer.getUIState().street.loading,
|
||||
false,
|
||||
'no spinner left behind',
|
||||
);
|
||||
// The stopped lookup answers late: nothing opens, nothing is reported.
|
||||
slow.lookups[0].answer.resolve('img1');
|
||||
assert.equal(await opening, false);
|
||||
assert.deepEqual(looking.calls.open, []);
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
});
|
||||
|
||||
test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
});
|
||||
const { layer: second } = await enabledLayer(t, [
|
||||
failing,
|
||||
fakeProvider({ id: 'other', pickPrefix: 'oth:' }),
|
||||
]);
|
||||
second.attachViewerHost({});
|
||||
await second.openNearest({ lat: 1, lon: 1 });
|
||||
assert.match(second.getUIState().street.error, /did not answer/);
|
||||
second.setParams({ mapillary: false, other: true });
|
||||
assert.equal(second.getUIState().street.error, null);
|
||||
// Reapplying the same switches (a restore) leaves state alone.
|
||||
await second.openNearest({ lat: 1, lon: 1 });
|
||||
const kept = second.getUIState().street.error;
|
||||
assert.match(kept, /No street-level imagery/);
|
||||
second.setParams({ mapillary: false, other: true });
|
||||
assert.equal(second.getUIState().street.error, kept);
|
||||
});
|
||||
@@ -0,0 +1,936 @@
|
||||
import * as Cesium from 'cesium';
|
||||
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 { tileBounds, tilesForBbox } from './tileMath.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;
|
||||
|
||||
/** 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, prefix) {
|
||||
return sequence.parts.map((_, index) =>
|
||||
index
|
||||
? `${prefix}${sequence.id}${PART_SEPARATOR}${index}`
|
||||
: `${prefix}${sequence.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Sequence id for any part's pick id (`<prefix><id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId, prefix) {
|
||||
if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix))
|
||||
return null;
|
||||
const rest = pickId.slice(prefix.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} LineCoverageTuning
|
||||
* @property {number} maxTiles Line tiles drawn at once.
|
||||
* @property {number} overviewMaxTiles Overview tiles drawn at once.
|
||||
* @property {number} maxSequences Lines drawn at once, over every tile.
|
||||
* @property {number} lineWidthPx
|
||||
* @property {number} overviewPointPx
|
||||
* @property {number} moveDebounceMs Camera rest before the tile set is recomputed.
|
||||
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
|
||||
* @property {number} viewRangePerHeight …or camera height times this.
|
||||
* @property {number} viewNearM Ground always covered around a tilted camera.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Camera-driven coverage from any XYZ line-tile source: overview points from
|
||||
* orbit, sequence lines 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.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags.
|
||||
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>, overview: Array<object>}>} options.fetchTile
|
||||
* A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview
|
||||
* points `{lon, lat, capturedAt, isPano}`. A rejection may carry
|
||||
* `keyRequired`, `keyRejected` or `retryAfterSec`.
|
||||
* @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor
|
||||
* Tile zoom for a camera height above ground; null draws nothing.
|
||||
* @param {{coverage: string, selected: string}} options.colors
|
||||
* @param {string} options.pickPrefix Pick-id prefix of the lines.
|
||||
* @param {string} options.renderKey Render-governor reason.
|
||||
* @param {LineCoverageTuning} options.tuning
|
||||
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
|
||||
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
|
||||
*/
|
||||
export function createLineCoverage({
|
||||
state,
|
||||
fetchTile,
|
||||
zoomFor,
|
||||
colors,
|
||||
pickPrefix,
|
||||
renderKey,
|
||||
tuning,
|
||||
messages,
|
||||
passes = passesImageryFilter,
|
||||
}) {
|
||||
const { render } = state.services;
|
||||
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
|
||||
|
||||
/** Colour for a line: the provider's, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
return selected
|
||||
? Cesium.Color.fromCssColorString(colors.selected)
|
||||
: Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92);
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.(renderKey);
|
||||
}
|
||||
|
||||
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 >= perTileSequenceCap) break;
|
||||
if (passes(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, pickPrefix);
|
||||
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: tuning.lineWidthPx,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
})
|
||||
: new Cesium.GroundPolylineGeometry({
|
||||
positions,
|
||||
width: tuning.lineWidthPx,
|
||||
});
|
||||
(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 tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha(
|
||||
0.85,
|
||||
);
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
for (const point of points) {
|
||||
if (!passes(point, current)) continue;
|
||||
drawn.push(
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
color: tint,
|
||||
pixelSize: tuning.overviewPointPx,
|
||||
// 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 [decoded] = await Promise.all([
|
||||
fetchTile(tile, { 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 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 =
|
||||
messages.keyRejected || state.coverage.lastError;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source 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 = messages.rateLimited;
|
||||
state.coverage.holdTimer = setTimeout(() => {
|
||||
state.coverage.holdTimer = null;
|
||||
state.coverage.holdUntil = 0;
|
||||
if (state.coverage.lastError === messages.rateLimited)
|
||||
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 level = zoomFor(height);
|
||||
const zoom = level?.zoom ?? null;
|
||||
const kind = level?.kind ?? null;
|
||||
// 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(
|
||||
tuning.viewRangeMinM,
|
||||
height * tuning.viewRangePerHeight,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
// 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) ? tuning.viewNearM : null,
|
||||
});
|
||||
if (kind === 'overview' && (!bbox || zoom <= 1))
|
||||
bbox = [-180, -85, 180, 85];
|
||||
if (zoom == null || !bbox) {
|
||||
state.coverage.hint =
|
||||
zoom == null && Number.isFinite(height) && messages.tooHigh
|
||||
? messages.tooHigh
|
||||
: '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' ? tuning.maxTiles : tuning.overviewMaxTiles,
|
||||
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, tuning.moveDebounceMs);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every tile again (a source that filters on its server had its
|
||||
* filter changed); the lines drawn now show until the new ones are in.
|
||||
*/
|
||||
function reload() {
|
||||
retire();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, pickPrefix)) {
|
||||
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,
|
||||
reload,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLineCoverage } from './lineCoverage.js';
|
||||
import {
|
||||
coverageViewer,
|
||||
tileCentre,
|
||||
} from '../../testSupport/streetLevelFakes.mjs';
|
||||
import { until } from '../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
const TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
maxSequences: 900,
|
||||
lineWidthPx: 2,
|
||||
overviewPointPx: 2,
|
||||
moveDebounceMs: 10,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Line coverage over one tile holding one line, on a viewer looking down from
|
||||
* `height`. `fetchTile` answers with that line unless `fail` says otherwise.
|
||||
*/
|
||||
function setup(
|
||||
t,
|
||||
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
|
||||
) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = coverageViewer(
|
||||
{ lon: centre.lon, lat: centre.lat, height: 900 },
|
||||
t,
|
||||
);
|
||||
const state = {
|
||||
viewer,
|
||||
services: {},
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
context: {
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
getSurface: () => 'draped',
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
},
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: Promise.resolve(),
|
||||
hint: '',
|
||||
},
|
||||
sequence: { selectedId: null },
|
||||
};
|
||||
const asked = [];
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile) => {
|
||||
asked.push(tile);
|
||||
if (fail) throw fail;
|
||||
return {
|
||||
sequences: [
|
||||
{
|
||||
id: 'line-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
},
|
||||
],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
zoomFor:
|
||||
zoomFor ??
|
||||
((height) =>
|
||||
Number.isFinite(height) && height <= 6_000
|
||||
? { zoom: 14, kind: 'sequence' }
|
||||
: null),
|
||||
colors: { coverage: '#635bff', selected: '#00e5ff' },
|
||||
pickPrefix: 'test:line:',
|
||||
renderKey: 'test-coverage',
|
||||
tuning: TUNING,
|
||||
messages,
|
||||
...(passes ? { passes } : {}),
|
||||
});
|
||||
t.after(() => {
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
return { viewer, state, coverage, asked };
|
||||
}
|
||||
|
||||
test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => {
|
||||
const tooHigh = 'zoom in to see test coverage';
|
||||
const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } });
|
||||
high.viewer.view.height = 7_000;
|
||||
high.coverage.refresh();
|
||||
assert.equal(high.state.coverage.hint, tooHigh);
|
||||
assert.equal(high.state.coverage.zoom, null);
|
||||
assert.equal(high.asked.length, 0, 'nothing is asked from up there');
|
||||
const generic = setup(t);
|
||||
generic.viewer.view.height = 7_000;
|
||||
generic.coverage.refresh();
|
||||
assert.notEqual(generic.state.coverage.hint, tooHigh);
|
||||
assert.match(generic.state.coverage.hint, /Point the camera/);
|
||||
});
|
||||
|
||||
test('a provider’s passes() decides which lines are drawn and counted', async (t) => {
|
||||
const all = setup(t, { passes: () => true });
|
||||
all.coverage.refresh();
|
||||
assert.ok(await until(() => all.state.coverage.tiles.size === 1));
|
||||
assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn');
|
||||
const none = setup(t, { passes: () => false });
|
||||
none.coverage.refresh();
|
||||
assert.ok(await until(() => none.state.coverage.tiles.size === 1));
|
||||
assert.equal(none.coverage.sequenceCount(), 0, 'filtered out');
|
||||
});
|
||||
|
||||
test('a refused key without its own message keeps the error it came with', async (t) => {
|
||||
const refused = Object.assign(new Error('the source refused the key'), {
|
||||
keyRejected: true,
|
||||
});
|
||||
const { state, coverage } = setup(t, { fail: refused });
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.keyRejected === true));
|
||||
assert.equal(state.coverage.lastError, 'the source refused the key');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { positionMarkerGlyph, viewConeMarkerGlyph } 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';
|
||||
|
||||
function markerLook(style) {
|
||||
if (style?.kind === 'view-cone') {
|
||||
const color = style.color || COLORS.position;
|
||||
return {
|
||||
key: `view-cone:${color}`,
|
||||
image: viewConeMarkerGlyph({ color }),
|
||||
size: 48,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'pin',
|
||||
image: positionMarkerGlyph({ color: COLORS.position }),
|
||||
size: 40,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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, last.style);
|
||||
});
|
||||
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, last.style);
|
||||
});
|
||||
|
||||
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
|
||||
function set(position, bearing, style = null) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
last = position ? { position, bearing, style } : 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;
|
||||
const look = markerLook(style);
|
||||
if (state.marker.billboard && state.marker.look !== look.key) {
|
||||
collection.remove?.(state.marker.billboard);
|
||||
state.marker.billboard = null;
|
||||
}
|
||||
if (!state.marker.billboard) {
|
||||
state.marker.look = look.key;
|
||||
state.marker.billboard = collection.add({
|
||||
id: POSITION_PICK_ID,
|
||||
position: cartesian,
|
||||
image: look.image,
|
||||
imageId: `sl-position:${look.key}`,
|
||||
width: look.size,
|
||||
height: look.size,
|
||||
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, last.style);
|
||||
}
|
||||
|
||||
return {
|
||||
ensure,
|
||||
set,
|
||||
clear: () => set(null),
|
||||
setVisible,
|
||||
setSurface,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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 }),
|
||||
remove() {},
|
||||
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);
|
||||
});
|
||||
|
||||
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
|
||||
const { state, marker } = setup();
|
||||
const removed = [];
|
||||
state.marker.collection.remove = (billboard) => removed.push(billboard);
|
||||
const streetView = { kind: 'view-cone', color: '#4285f4' };
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
|
||||
const cone = state.marker.billboard;
|
||||
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
|
||||
assert.equal(
|
||||
cone.rotation,
|
||||
-Cesium.Math.toRadians(45),
|
||||
'it turns with the view',
|
||||
);
|
||||
// Dragging the panorama keeps the look and the billboard.
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
|
||||
assert.equal(state.marker.billboard, cone);
|
||||
// Re-placed for a surface change: still the cone.
|
||||
marker.setSurface();
|
||||
assert.equal(state.marker.billboard, cone);
|
||||
// A Mapillary photo next (its provider has no marker): the pin.
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
|
||||
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
|
||||
assert.deepEqual(removed, [cone]);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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]],
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 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',
|
||||
google: '#4285f4',
|
||||
kartaview: '#635bff',
|
||||
});
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Above this, a ground click is a misclick, not a street. */
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
|
||||
export const AIM_AT_GROUND_MESSAGE =
|
||||
'Aim the middle of the view at a street to look for imagery there';
|
||||
@@ -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,184 @@
|
||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
GOOGLE_CREDIT_HTML,
|
||||
GOOGLE_KEY_ID,
|
||||
GOOGLE_LABEL,
|
||||
GOOGLE_NAME,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
GROUND_CLICK_HINT,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NEAREST_RADIUS_M,
|
||||
NEAREST_TIMEOUT_MS,
|
||||
NO_ANSWER_MESSAGE,
|
||||
PICK_PREFIX,
|
||||
} from './policy.js';
|
||||
|
||||
/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */
|
||||
function settleWithin(promise, { ms, signal, loader }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
|
||||
ms,
|
||||
);
|
||||
const onAbort = () => finish(reject, signal.reason);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const stopAuthWatch = loader.onAuthFailure(() =>
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
promise.then(
|
||||
(value) => finish(resolve, value),
|
||||
(error) => finish(reject, error),
|
||||
);
|
||||
function finish(settle, value) {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
stopAuthWatch();
|
||||
settle(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Street View provider: Google's panorama viewer and nearest-panorama
|
||||
* lookups. It draws no coverage; a click on a street opens the panorama.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
getApiKey,
|
||||
loader = createMapsLoader({ getApiKey }),
|
||||
}) {
|
||||
if (typeof getApiKey !== 'function')
|
||||
throw new TypeError('Google Street View needs getApiKey');
|
||||
|
||||
return Object.freeze({
|
||||
id: GOOGLE_PROVIDER_ID,
|
||||
name: GOOGLE_NAME,
|
||||
label: GOOGLE_LABEL,
|
||||
requiresKeyId: GOOGLE_KEY_ID,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
||||
groundClick: true,
|
||||
// Google's terms forbid Street View beside non-Google maps.
|
||||
requiresMapStack: FOLLOW_MAP_STACK_ID,
|
||||
// Every panorama is billed to the Maps key.
|
||||
defaultOn: false,
|
||||
// All 360°, and no coverage for a date filter to thin.
|
||||
filters: [],
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
|
||||
create(context) {
|
||||
let keyRequired = !getApiKey();
|
||||
let active = false;
|
||||
let service = null;
|
||||
async function getService() {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
service ||= new lib.StreetViewService();
|
||||
return service;
|
||||
}
|
||||
const viewer = createGoogleViewer({
|
||||
loader,
|
||||
getService,
|
||||
render: context.services?.render,
|
||||
});
|
||||
const stopAuthWatch = loader.onAuthFailure(() => {
|
||||
// Switched off: its chip says so.
|
||||
if (context.isActive?.())
|
||||
context.actions.reportError(KEY_REJECTED_MESSAGE);
|
||||
context.notify();
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
keyRequired = !getApiKey();
|
||||
context.notify();
|
||||
return { configured: !keyRequired };
|
||||
},
|
||||
|
||||
init() {},
|
||||
|
||||
activate() {
|
||||
active = true;
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
active = false;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
active = false;
|
||||
stopAuthWatch();
|
||||
},
|
||||
|
||||
refreshCoverage() {},
|
||||
|
||||
/** No filter applies to Street View (see `filters`). */
|
||||
setFilter() {},
|
||||
|
||||
coverageStats() {
|
||||
const keyRejected = loader.authFailed();
|
||||
return {
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint:
|
||||
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
|
||||
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
|
||||
keyRequired,
|
||||
keyRejected,
|
||||
};
|
||||
},
|
||||
|
||||
handlePick: () => false,
|
||||
|
||||
/**
|
||||
* Nearest Google-collected outdoor panorama, or null. The imagery
|
||||
* filters do not apply (see `filters`).
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
signal?.throwIfAborted();
|
||||
// Set once the caller has its answer (aborted, timed out, refused):
|
||||
// a lookup still waiting on Google's script then asks nothing.
|
||||
let settled = false;
|
||||
const lookup = (async () => {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
await getService();
|
||||
if (settled) return null;
|
||||
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
|
||||
try {
|
||||
const { data } = await service.getPanorama({
|
||||
location: { lat, lng: lon },
|
||||
radius: NEAREST_RADIUS_M,
|
||||
// Both: Google's own imagery, outdoors (sources intersect).
|
||||
sources: [GOOGLE, OUTDOOR],
|
||||
preference: lib.StreetViewPreference.NEAREST,
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ZERO_RESULTS') return null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
const data = await settleWithin(lookup, {
|
||||
ms: NEAREST_TIMEOUT_MS,
|
||||
signal,
|
||||
loader,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const panoId = data?.location?.pano;
|
||||
if (!panoId) return null;
|
||||
viewer.remember(panoId, data);
|
||||
return panoId;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Loads the Google Maps JavaScript API once per page, on first use. Google's
|
||||
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
|
||||
*/
|
||||
|
||||
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
|
||||
const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
/** The page's one script load, shared by every loader on it. */
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
*/
|
||||
export function createMapsLoader({
|
||||
getApiKey,
|
||||
document = globalThis.document,
|
||||
global = globalThis,
|
||||
}) {
|
||||
let authFailed = false;
|
||||
let authHandlerInstalled = false;
|
||||
const authListeners = new Set();
|
||||
|
||||
/** Once per loader: a retry must not wrap its own handler. */
|
||||
function installAuthHandler() {
|
||||
if (authHandlerInstalled) return;
|
||||
authHandlerInstalled = true;
|
||||
const previous = global.gm_authFailure;
|
||||
global.gm_authFailure = () => {
|
||||
authFailed = true;
|
||||
for (const listener of [...authListeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* a listener's error is its own */
|
||||
}
|
||||
}
|
||||
if (typeof previous === 'function') previous();
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
const key = getApiKey?.();
|
||||
if (!key)
|
||||
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
|
||||
installAuthHandler();
|
||||
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
|
||||
if (global.google?.maps?.importLibrary)
|
||||
return Promise.resolve(global.google.maps);
|
||||
const loading = new Promise((resolve, reject) => {
|
||||
global[READY_CALLBACK] = () => {
|
||||
delete global[READY_CALLBACK];
|
||||
resolve(global.google.maps);
|
||||
};
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
|
||||
key,
|
||||
v: 'weekly',
|
||||
loading: 'async',
|
||||
callback: READY_CALLBACK,
|
||||
})}`;
|
||||
script.onerror = () => {
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
delete global[READY_CALLBACK];
|
||||
reject(new Error('The Google Maps JavaScript API could not load'));
|
||||
};
|
||||
document.head.append(script);
|
||||
});
|
||||
global[PAGE_LOAD] = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
async importLibrary(name) {
|
||||
const maps = await load();
|
||||
return maps.importLibrary(name);
|
||||
},
|
||||
authFailed: () => authFailed,
|
||||
onAuthFailure(listener) {
|
||||
authListeners.add(listener);
|
||||
return () => authListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
|
||||
/** A document whose appended scripts the test loads or fails. */
|
||||
function fakePage() {
|
||||
const scripts = [];
|
||||
const document = {
|
||||
createElement: () => {
|
||||
const script = {
|
||||
remove() {
|
||||
scripts.splice(scripts.indexOf(script), 1);
|
||||
},
|
||||
};
|
||||
return script;
|
||||
},
|
||||
head: { append: (script) => scripts.push(script) },
|
||||
};
|
||||
const global = {};
|
||||
const library = { name: 'streetView' };
|
||||
/** Google's script arrived: it defines google.maps and calls back. */
|
||||
const loadScript = () => {
|
||||
global.google = {
|
||||
maps: { importLibrary: async (name) => ({ ...library, name }) },
|
||||
};
|
||||
const callback = new URL(scripts.at(-1).src).searchParams.get('callback');
|
||||
global[callback]();
|
||||
};
|
||||
return { document, global, scripts, loadScript };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('the API loads once, with the key, asynchronously, and hands out libraries', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k-1', ...page });
|
||||
const first = loader.importLibrary('streetView');
|
||||
const second = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1, 'one script for concurrent imports');
|
||||
const url = new URL(page.scripts[0].src);
|
||||
assert.equal(
|
||||
url.origin + url.pathname,
|
||||
'https://maps.googleapis.com/maps/api/js',
|
||||
);
|
||||
assert.equal(url.searchParams.get('key'), 'k-1');
|
||||
assert.equal(url.searchParams.get('loading'), 'async');
|
||||
assert.equal(page.scripts[0].async, true);
|
||||
page.loadScript();
|
||||
assert.equal((await first).name, 'streetView');
|
||||
assert.equal((await second).name, 'streetView');
|
||||
const callback = url.searchParams.get('callback');
|
||||
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
|
||||
});
|
||||
|
||||
test('two loaders on one page share one script and both get the API', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const one = first.importLibrary('streetView');
|
||||
const two = second.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1, 'one script for the page');
|
||||
page.loadScript();
|
||||
assert.equal((await one).name, 'streetView');
|
||||
assert.equal((await two).name, 'streetView');
|
||||
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
|
||||
});
|
||||
|
||||
test('after a failed script, either loader on the page can retry it', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const failed = first.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed, /could not load/);
|
||||
const retry = second.importLibrary('streetView');
|
||||
const again = first.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1);
|
||||
page.loadScript();
|
||||
assert.equal((await retry).name, 'streetView');
|
||||
assert.equal((await again).name, 'streetView');
|
||||
});
|
||||
|
||||
test('without a key nothing loads', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => null, ...page });
|
||||
await assert.rejects(loader.importLibrary('streetView'), /not set/);
|
||||
assert.equal(page.scripts.length, 0);
|
||||
});
|
||||
|
||||
test('a script that fails to load can be retried', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const failed = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed, /could not load/);
|
||||
assert.equal(page.scripts.length, 0, 'the failed script is removed');
|
||||
const retry = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1);
|
||||
page.loadScript();
|
||||
assert.equal((await retry).name, 'streetView');
|
||||
});
|
||||
|
||||
test("Google's refusal of the key reaches every listener and the page's own handler", async () => {
|
||||
const page = fakePage();
|
||||
let before = 0;
|
||||
page.global.gm_authFailure = () => before++;
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const heard = [];
|
||||
const stop = loader.onAuthFailure(() => heard.push('a'));
|
||||
loader.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(loader.authFailed(), false);
|
||||
page.global.gm_authFailure();
|
||||
assert.equal(loader.authFailed(), true);
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1, 'a handler installed earlier still runs');
|
||||
stop();
|
||||
page.global.gm_authFailure();
|
||||
assert.deepEqual(heard, ['a']);
|
||||
});
|
||||
|
||||
test('a retried load still reports a refusal once, and the page handler once', async () => {
|
||||
const page = fakePage();
|
||||
let before = 0;
|
||||
page.global.gm_authFailure = () => before++;
|
||||
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const heard = [];
|
||||
loader.onAuthFailure(() => heard.push('a'));
|
||||
const failed = loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed);
|
||||
loader.importLibrary('streetView');
|
||||
await settle();
|
||||
page.global.gm_authFailure();
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
export const GOOGLE_PROVIDER_ID = 'google';
|
||||
export const GOOGLE_NAME = 'Google Street View';
|
||||
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
|
||||
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||
export const GOOGLE_KEY_ID = 'google-maps';
|
||||
|
||||
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
|
||||
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.google,
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
export const GOOGLE_CREDIT_HTML =
|
||||
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/** Shown when Google refuses the key (gm_authFailure). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
|
||||
|
||||
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
export const OPEN_TIMEOUT_MS = 20_000;
|
||||
export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Pause after a failed details lookup before a view event can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
|
||||
|
||||
export const GROUND_CLICK_HINT = 'click a street to open Street View';
|
||||
|
||||
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
api: '1',
|
||||
map_action: 'pano',
|
||||
pano: String(panoId),
|
||||
heading: String(Math.round(heading)),
|
||||
pitch: String(Math.round(pitch)),
|
||||
});
|
||||
return `https://www.google.com/maps/@?${params}`;
|
||||
}
|
||||
|
||||
/** Epoch ms for a Street View image date ('YYYY-MM'), or null. */
|
||||
export function imageDateMs(imageDate) {
|
||||
const match = /^(\d{4})-(\d{2})/.exec(String(imageDate ?? ''));
|
||||
if (!match) return null;
|
||||
const month = Number(match[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
return Date.UTC(Number(match[1]), month - 1, 1);
|
||||
}
|
||||
|
||||
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
|
||||
export function creatorFrom(copyright) {
|
||||
const text = String(copyright ?? '')
|
||||
.replace(/^©\s*(\d{4}\s+)?/, '')
|
||||
.replace(/^From the Owner,\s*Photo by:\s*/i, '')
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleProvider } from './index.js';
|
||||
import {
|
||||
GROUND_CLICK_HINT,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
creatorFrom,
|
||||
imageDateMs,
|
||||
streetViewUrl,
|
||||
} from './policy.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
|
||||
import { createMapillaryProvider } from '../mapillary/index.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
fakeStreetViewLibrary,
|
||||
} from '../../../../testSupport/googleStreetViewFakes.mjs';
|
||||
|
||||
const PANORAMAS = {
|
||||
fresh: { lat: 38.5816, lng: -121.4944, imageDate: '2025-06' },
|
||||
old: { lat: 38.6, lng: -121.5, imageDate: '2009-03' },
|
||||
};
|
||||
|
||||
function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS, nearest });
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
let apiKey = key;
|
||||
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
|
||||
const reported = [];
|
||||
let filter = { pano: 'all', sinceMs: null };
|
||||
let active = false;
|
||||
const context = {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
isActive: () => active,
|
||||
notify() {},
|
||||
actions: { reportError: (message) => reported.push(message) },
|
||||
};
|
||||
const created = def.create(context);
|
||||
// The core switches it on and off; mirror that for isActive().
|
||||
const instance = {
|
||||
...created,
|
||||
activate() {
|
||||
active = true;
|
||||
created.activate();
|
||||
},
|
||||
deactivate() {
|
||||
active = false;
|
||||
created.deactivate();
|
||||
},
|
||||
};
|
||||
return {
|
||||
def,
|
||||
instance,
|
||||
fake,
|
||||
loader,
|
||||
reported,
|
||||
setFilter: (next) => (filter = next),
|
||||
setKey: (next) => (apiKey = next),
|
||||
};
|
||||
}
|
||||
|
||||
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
|
||||
const { def } = setup();
|
||||
assert.equal(def.id, 'google');
|
||||
assert.equal(def.label, 'GOOGLE STREET VIEW');
|
||||
assert.deepEqual(def.filters, [], 'no imagery filter applies');
|
||||
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
|
||||
assert.equal(def.requiresKeyId, 'google-maps');
|
||||
assert.equal(def.defaultOn, false, 'every panorama is billed');
|
||||
assert.equal(def.groundClick, true);
|
||||
assert.equal(def.colors.coverage, PROVIDER_COLORS.google);
|
||||
assert.match(def.credit.html, /Street View imagery © .*Google/);
|
||||
const mapillary = createMapillaryProvider({
|
||||
source: {
|
||||
getStatus() {},
|
||||
getTile() {},
|
||||
getSequenceImages() {},
|
||||
nearestImages() {},
|
||||
},
|
||||
});
|
||||
const both = validateProviders([mapillary, def]);
|
||||
assert.equal(requiresKeyIdFor(both), null, 'each chip gates on its own key');
|
||||
assert.throws(() => createGoogleProvider({}), /getApiKey/);
|
||||
});
|
||||
|
||||
test('status follows the browser Maps key', async () => {
|
||||
const { instance, setKey } = setup({ key: '' });
|
||||
assert.deepEqual(await instance.status(), { configured: false });
|
||||
assert.equal(instance.coverageStats().keyRequired, true);
|
||||
setKey('browser-key');
|
||||
assert.deepEqual(await instance.status(), { configured: true });
|
||||
assert.equal(instance.coverageStats().keyRequired, false);
|
||||
});
|
||||
|
||||
test('the nearest lookup asks Google for its own outdoor imagery within 50 m', async () => {
|
||||
const { instance, fake } = setup();
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 38.58, lon: -121.49 }),
|
||||
'fresh',
|
||||
);
|
||||
const [request] = fake.lookups;
|
||||
assert.deepEqual(request.location, { lat: 38.58, lng: -121.49 });
|
||||
assert.equal(request.radius, 50);
|
||||
assert.deepEqual(request.sources, ['google', 'outdoor']);
|
||||
assert.equal(request.preference, 'nearest');
|
||||
});
|
||||
|
||||
test('no panorama nearby is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
|
||||
const none = setup({ nearest: () => null });
|
||||
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
|
||||
|
||||
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
|
||||
for (const filter of [
|
||||
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
|
||||
{ pano: 'flat', sinceMs: null },
|
||||
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
|
||||
]) {
|
||||
setFilter(filter);
|
||||
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
|
||||
}
|
||||
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
|
||||
instance.activate();
|
||||
setFilter({ pano: 'flat', sinceMs: null });
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
});
|
||||
|
||||
test('an aborted lookup gives up before asking Google', async () => {
|
||||
const { instance } = setup();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
instance.nearestImage({ lat: 0, lon: 0 }, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a key Google refuses reads KEY REJECTED, shows why, and stops lookups', async () => {
|
||||
const { instance, loader, reported } = setup();
|
||||
instance.activate();
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
loader.refuseKey();
|
||||
const stats = instance.coverageStats();
|
||||
assert.equal(stats.keyRejected, true);
|
||||
assert.equal(stats.error, KEY_REJECTED_MESSAGE);
|
||||
assert.equal(stats.hint, '', 'no "click a street" hint over a dead key');
|
||||
assert.deepEqual(reported, [KEY_REJECTED_MESSAGE]);
|
||||
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
|
||||
message: KEY_REJECTED_MESSAGE,
|
||||
});
|
||||
instance.destroy();
|
||||
assert.equal(loader.listenerCount(), 0, 'destroy stops listening');
|
||||
});
|
||||
|
||||
test('the hint shows only while the provider is on', () => {
|
||||
const { instance } = setup();
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
instance.activate();
|
||||
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
|
||||
instance.deactivate();
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
});
|
||||
|
||||
test('image dates, photographers and deep links read the way Google writes them', () => {
|
||||
assert.equal(imageDateMs('2024-05'), Date.UTC(2024, 4, 1));
|
||||
assert.equal(imageDateMs('2024-13'), null);
|
||||
assert.equal(imageDateMs(undefined), null);
|
||||
assert.equal(creatorFrom('© 2024 Google'), 'Google');
|
||||
assert.equal(creatorFrom('From the Owner, Photo by: Ada'), 'Ada');
|
||||
assert.equal(creatorFrom(''), null);
|
||||
assert.equal(
|
||||
streetViewUrl('a b', { heading: 90.4, pitch: -9.6 }),
|
||||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=a+b&heading=90&pitch=-10',
|
||||
);
|
||||
});
|
||||
|
||||
test('a lookup Google never answers fails after the timeout, and a refused key fails it at once', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
|
||||
const { instance, fake, loader } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {});
|
||||
const hung = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
await assert.rejects(hung, { message: NO_ANSWER_MESSAGE });
|
||||
|
||||
const refused = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
await Promise.resolve();
|
||||
loader.refuseKey();
|
||||
await assert.rejects(refused, { message: KEY_REJECTED_MESSAGE });
|
||||
assert.equal(
|
||||
loader.listenerCount(),
|
||||
1,
|
||||
'only the provider’s own watch is left',
|
||||
);
|
||||
});
|
||||
|
||||
test('aborting a lookup in flight gives up at once', async () => {
|
||||
const { instance, fake } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {});
|
||||
const controller = new AbortController();
|
||||
const lookup = instance.nearestImage(
|
||||
{ lat: 0, lon: 0 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
await assert.rejects(lookup, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: PANORAMAS,
|
||||
nearest: () => 'fresh',
|
||||
});
|
||||
const loader = fakeMapsLoader(fake.library, { deferred: true });
|
||||
const instance = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader,
|
||||
}).create({
|
||||
services: {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
actions: { reportError() {} },
|
||||
});
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
// Aborted, then timed out, while the script still loads.
|
||||
const controller = new AbortController();
|
||||
const aborted = instance.nearestImage(
|
||||
{ lat: 0, lon: 0 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
await assert.rejects(aborted, { name: 'AbortError' });
|
||||
const expired = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
await assert.rejects(expired, /did not answer/);
|
||||
// The script arrives: a current lookup still gets its answer.
|
||||
const current = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
loader.release();
|
||||
await flush();
|
||||
assert.equal(await current, 'fresh');
|
||||
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
|
||||
});
|
||||
|
||||
test('a Google outage is an error, not "no imagery here"', async () => {
|
||||
const { instance, fake } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error('UNKNOWN_ERROR'), {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
endpoint: 'STREETVIEW_GET_PANORAMA',
|
||||
}),
|
||||
);
|
||||
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
test('a refusal while the provider is off reports no error over another photo', () => {
|
||||
const { instance, loader, reported } = setup();
|
||||
loader.refuseKey();
|
||||
assert.deepEqual(reported, []);
|
||||
assert.equal(
|
||||
instance.coverageStats().keyRejected,
|
||||
true,
|
||||
'its chip still says so',
|
||||
);
|
||||
});
|
||||
|
||||
test('the panorama a lookup found is dated without a second lookup', async () => {
|
||||
const { instance, fake } = setup();
|
||||
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
|
||||
const host = fakeHost();
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose));
|
||||
await instance.viewer.mount(host);
|
||||
await instance.viewer.open('fresh');
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
|
||||
assert.deepEqual(
|
||||
fake.lookups.filter((request) => request.pano),
|
||||
[],
|
||||
'no getPanorama({pano}) for a panorama the lookup already described',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
DETAILS_RETRY_COOLDOWN_MS,
|
||||
DETAILS_TIMEOUT_MS,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NO_ANSWER_MESSAGE,
|
||||
OPEN_TIMEOUT_MS,
|
||||
creatorFrom,
|
||||
imageDateMs,
|
||||
streetViewUrl,
|
||||
} from './policy.js';
|
||||
|
||||
const PANORAMA_OPTIONS = Object.freeze({
|
||||
visible: false,
|
||||
// The caption has the address; Google's card covers a docked panorama.
|
||||
addressControl: false,
|
||||
linksControl: true,
|
||||
panControl: true,
|
||||
zoomControl: true,
|
||||
clickToGo: true,
|
||||
showRoadLabels: true,
|
||||
// The panel has EXPAND and ×.
|
||||
fullscreenControl: false,
|
||||
enableCloseButton: false,
|
||||
motionTracking: false,
|
||||
motionTrackingControl: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter over Google's StreetViewPanorama: emits a pose for every
|
||||
* panorama and view change, including steps along Google's arrows.
|
||||
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
|
||||
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
|
||||
*/
|
||||
export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
let library = null;
|
||||
let panorama = null;
|
||||
let element = null;
|
||||
let host = null;
|
||||
let service = null;
|
||||
let pendingOpen = null;
|
||||
let cancelShown = null;
|
||||
/** Bumped by `unmount`: a construction it overtook builds nothing. */
|
||||
let generation = 0;
|
||||
let subscriptions = [];
|
||||
const described = new Map();
|
||||
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
|
||||
const lookingUp = new Map();
|
||||
const retryAfter = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('google-street-view');
|
||||
}
|
||||
|
||||
async function ensureLibrary() {
|
||||
library ||= await loader.importLibrary('streetView');
|
||||
return library;
|
||||
}
|
||||
|
||||
function emit(pose) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function metaFrom(data) {
|
||||
return {
|
||||
capturedAt: imageDateMs(data?.imageDate),
|
||||
creator: creatorFrom(data?.copyright),
|
||||
title: data?.location?.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a panorama's date, photographer and address once, then re-emit
|
||||
* the pose. A failed or timed-out lookup can retry after the cooldown.
|
||||
*/
|
||||
async function describe(panoId) {
|
||||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||||
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
|
||||
const lookup = { timer: null };
|
||||
lookingUp.set(panoId, lookup);
|
||||
const current = () => lookingUp.get(panoId) === lookup;
|
||||
try {
|
||||
const { data } = await new Promise((resolve, reject) => {
|
||||
lookup.timer = setTimeout(
|
||||
() => reject(new Error(NO_ANSWER_MESSAGE)),
|
||||
DETAILS_TIMEOUT_MS,
|
||||
);
|
||||
(async () => {
|
||||
service ||= getService
|
||||
? await getService()
|
||||
: new (await ensureLibrary()).StreetViewService();
|
||||
return service.getPanorama({ pano: panoId });
|
||||
})().then(resolve, reject);
|
||||
});
|
||||
if (!current()) return;
|
||||
described.set(panoId, metaFrom(data));
|
||||
retryAfter.delete(panoId);
|
||||
} catch {
|
||||
if (current())
|
||||
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(lookup.timer);
|
||||
if (current()) lookingUp.delete(panoId);
|
||||
}
|
||||
publishPose();
|
||||
}
|
||||
|
||||
function releaseLookups() {
|
||||
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
|
||||
lookingUp.clear();
|
||||
}
|
||||
|
||||
function publishPose() {
|
||||
const panoId = panorama?.getPano?.();
|
||||
const latLng = panorama?.getPosition?.();
|
||||
if (!panoId || !latLng || pendingOpen === null) return;
|
||||
const meta = described.get(panoId);
|
||||
if (!meta) describe(panoId);
|
||||
const pov = panorama.getPov() || {};
|
||||
emit({
|
||||
providerId: GOOGLE_PROVIDER_ID,
|
||||
imageId: panoId,
|
||||
position: { lon: latLng.lng(), lat: latLng.lat() },
|
||||
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
|
||||
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: true,
|
||||
capturedAt: meta?.capturedAt ?? null,
|
||||
capturedAtPrecision: 'month',
|
||||
creator: meta?.creator ?? null,
|
||||
title: meta?.title ?? null,
|
||||
sequenceId: null,
|
||||
externalUrl: streetViewUrl(panoId, {
|
||||
heading: pov.heading || 0,
|
||||
pitch: pov.pitch || 0,
|
||||
}),
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
async function ensurePanorama(target) {
|
||||
if (panorama && host === target) return panorama;
|
||||
const built = generation;
|
||||
const { StreetViewPanorama } = await ensureLibrary();
|
||||
if (built !== generation) throw new Error('Street View was unmounted');
|
||||
if (panorama && host === target) return panorama;
|
||||
destroyPanorama();
|
||||
element = target.ownerDocument.createElement('div');
|
||||
element.className = 'sl-google-panorama';
|
||||
element.style.cssText = 'position:absolute;inset:0;';
|
||||
target.append(element);
|
||||
host = target;
|
||||
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
|
||||
// Not pano_changed: it can fire with the old panorama's position.
|
||||
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
|
||||
panorama.addListener(name, () => publishPose()),
|
||||
);
|
||||
return panorama;
|
||||
}
|
||||
|
||||
function destroyPanorama() {
|
||||
for (const subscription of subscriptions) subscription.remove();
|
||||
subscriptions = [];
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
element?.remove();
|
||||
panorama = null;
|
||||
element = null;
|
||||
host = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once `panoId` shows (OK status, or a new position when the
|
||||
* status stays OK); rejects on no imagery, a refused key or no answer.
|
||||
*/
|
||||
function shown(instance, panoId) {
|
||||
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
|
||||
return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
|
||||
OPEN_TIMEOUT_MS,
|
||||
);
|
||||
const stopAuthWatch = loader.onAuthFailure(() =>
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
cancelShown = () => finish(resolve);
|
||||
// At a position event the status may still be the last panorama's failure.
|
||||
const onStatus = () => {
|
||||
if (pendingOpen !== panoId) return finish(resolve);
|
||||
const status = instance.getStatus();
|
||||
if (status === 'OK') finish(resolve);
|
||||
else if (status)
|
||||
finish(
|
||||
reject,
|
||||
new Error('Street View has no imagery for this place'),
|
||||
);
|
||||
};
|
||||
const onPosition = () => {
|
||||
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
|
||||
finish(resolve);
|
||||
};
|
||||
const subscriptions = [
|
||||
instance.addListener('status_changed', onStatus),
|
||||
instance.addListener('position_changed', onPosition),
|
||||
];
|
||||
function finish(settle, value) {
|
||||
cancelShown = null;
|
||||
clearTimeout(timer);
|
||||
for (const subscription of subscriptions) subscription.remove();
|
||||
stopAuthWatch();
|
||||
settle(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(target) {
|
||||
if (!target) throw new Error('Street Level viewer has no host element');
|
||||
await ensurePanorama(target);
|
||||
},
|
||||
|
||||
async open(imageId) {
|
||||
const panoId = String(imageId);
|
||||
if (!host) throw new Error('Street View is not mounted');
|
||||
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
|
||||
pendingOpen = panoId;
|
||||
const instance = await ensurePanorama(host);
|
||||
if (pendingOpen !== panoId) return;
|
||||
const ready = shown(instance, panoId);
|
||||
instance.setPano(panoId);
|
||||
instance.setVisible(true);
|
||||
await ready;
|
||||
if (pendingOpen !== panoId) return;
|
||||
publishPose();
|
||||
},
|
||||
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
cancelShown?.();
|
||||
try {
|
||||
panorama?.setVisible(false);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
cancelShown?.();
|
||||
releaseLookups();
|
||||
generation++;
|
||||
destroyPanorama();
|
||||
},
|
||||
|
||||
resize() {
|
||||
if (!panorama) return;
|
||||
try {
|
||||
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
/** Seed the details cache from a nearest lookup's answer. */
|
||||
remember(panoId, data) {
|
||||
if (panoId && data) described.set(String(panoId), metaFrom(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
fakeStreetViewLibrary,
|
||||
} from '../../../../testSupport/googleStreetViewFakes.mjs';
|
||||
|
||||
const PANORAMAS = {
|
||||
'pano-a': {
|
||||
lat: 38.5816,
|
||||
lng: -121.4944,
|
||||
imageDate: '2024-05',
|
||||
copyright: '© 2024 Google',
|
||||
description: '10th St',
|
||||
},
|
||||
'pano-b': {
|
||||
lat: 38.5817,
|
||||
lng: -121.4944,
|
||||
imageDate: '2019-08',
|
||||
copyright: 'From the Owner, Photo by: Ada',
|
||||
},
|
||||
};
|
||||
|
||||
function setup() {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const viewer = createGoogleViewer({ loader });
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
const host = fakeHost();
|
||||
return { fake, loader, viewer, poses, host };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('opening a panorama shows it and emits a provider-neutral pose', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
assert.equal(host.children.length, 1, 'renders in its own element');
|
||||
const [panorama] = fake.built;
|
||||
assert.equal(panorama.options.fullscreenControl, false);
|
||||
assert.equal(panorama.options.addressControl, false, 'the caption has it');
|
||||
assert.equal(panorama.options.enableCloseButton, false);
|
||||
assert.equal(panorama.visible, false, 'nothing shows before an open');
|
||||
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(panorama.visible, true);
|
||||
await settle(); // the pose is dated once its lookup answers
|
||||
const pose = poses.at(-1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
providerId: pose.providerId,
|
||||
imageId: pose.imageId,
|
||||
position: pose.position,
|
||||
isPano: pose.isPano,
|
||||
capturedAt: pose.capturedAt,
|
||||
capturedAtPrecision: pose.capturedAtPrecision,
|
||||
creator: pose.creator,
|
||||
title: pose.title,
|
||||
},
|
||||
{
|
||||
providerId: 'google',
|
||||
imageId: 'pano-a',
|
||||
position: { lon: -121.4944, lat: 38.5816 },
|
||||
isPano: true,
|
||||
capturedAt: Date.UTC(2024, 4, 1),
|
||||
capturedAtPrecision: 'month',
|
||||
creator: 'Google',
|
||||
title: '10th St',
|
||||
},
|
||||
);
|
||||
assert.match(pose.externalUrl, /map_action=pano&pano=pano-a/);
|
||||
});
|
||||
|
||||
test('dragging the view and stepping along an arrow both emit poses; the date is looked up once per panorama', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
panorama.setPov({ heading: 90, pitch: -10 });
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
[poses.at(-1).bearing, poses.at(-1).tilt],
|
||||
[90, -10],
|
||||
'the view follows the drag',
|
||||
);
|
||||
// Google's own arrow: the panorama changes without an open.
|
||||
panorama.setPano('pano-b');
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b');
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
const dateLookups = fake.lookups.filter((request) => request.pano);
|
||||
assert.deepEqual(
|
||||
dateLookups.map((request) => request.pano),
|
||||
['pano-a', 'pano-b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a panorama Google has no imagery for fails the open', async () => {
|
||||
const { viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await assert.rejects(viewer.open('gone'), /no imagery/);
|
||||
});
|
||||
|
||||
test('a closed panorama is hidden and its late view changes emit nothing', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
viewer.close();
|
||||
assert.equal(panorama.visible, false);
|
||||
const count = poses.length;
|
||||
panorama.setPov({ heading: 180, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
|
||||
test('an open overtaken by a newer one only shows the newer panorama', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const first = viewer.open('pano-a');
|
||||
const second = viewer.open('pano-b');
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
[...new Set(poses.map((pose) => pose.imageId))].at(-1),
|
||||
'pano-b',
|
||||
);
|
||||
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
|
||||
});
|
||||
|
||||
test('unmounting removes the element and every listener', async () => {
|
||||
const { fake, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
assert.ok(panorama.listenerCount() > 0);
|
||||
viewer.unmount();
|
||||
assert.equal(host.children.length, 0);
|
||||
assert.equal(panorama.listenerCount(), 0);
|
||||
await assert.rejects(viewer.open('pano-a'), /not mounted/);
|
||||
});
|
||||
|
||||
test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
|
||||
const { viewer, loader } = setup();
|
||||
assert.equal(viewer.prewarm, undefined);
|
||||
assert.deepEqual(loader.imports, []);
|
||||
});
|
||||
|
||||
test('a key Google refuses fails the open at once, not after the timeout', async () => {
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
// Google refuses the key while the panorama loads; it never reports OK.
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id;
|
||||
queueMicrotask(() => loader.refuseKey());
|
||||
};
|
||||
await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
|
||||
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
|
||||
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
|
||||
});
|
||||
|
||||
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
|
||||
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
|
||||
const loader = fakeMapsLoader(fake.library, { deferred: true });
|
||||
const viewer = createGoogleViewer({ loader });
|
||||
const host = fakeHost();
|
||||
const mounting = viewer.mount(host);
|
||||
viewer.unmount();
|
||||
loader.release();
|
||||
await assert.rejects(mounting, /unmounted/);
|
||||
assert.equal(fake.built.length, 0);
|
||||
assert.equal(host.children.length, 0);
|
||||
});
|
||||
|
||||
test('a date that arrives after the photo was closed emits nothing', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
const [panorama] = fake.built;
|
||||
let answer;
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
|
||||
assert.equal(
|
||||
poses.at(-1).capturedAt,
|
||||
null,
|
||||
'undated until the lookup answers',
|
||||
);
|
||||
viewer.close();
|
||||
const count = poses.length;
|
||||
answer({ data: { imageDate: '2019-08' } });
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
|
||||
test('an open never waits on the date lookup', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
new Promise(() => {}); // the metadata never answers
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
viewer.unmount();
|
||||
});
|
||||
|
||||
test('closing while a panorama loads settles the open and drops its listeners', async () => {
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
const before = panorama.listenerCount();
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id; // still loading
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await settle();
|
||||
viewer.close();
|
||||
await opening;
|
||||
assert.equal(panorama.listenerCount(), before);
|
||||
assert.equal(loader.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('a second open whose status stays OK still resolves, on the new position', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(fake.built[0].getStatus(), 'OK');
|
||||
await viewer.open('pano-b'); // OK → OK: no status_changed
|
||||
assert.equal(poses.at(-1).imageId, 'pano-b');
|
||||
});
|
||||
|
||||
test('Google answering with a newer id for the place shows that id', async () => {
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: PANORAMAS,
|
||||
substitutes: { 'old-id': 'pano-a' },
|
||||
});
|
||||
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
await viewer.mount(fakeHost());
|
||||
await viewer.open('old-id');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
});
|
||||
|
||||
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
|
||||
const { fake, loader, viewer, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const [panorama] = fake.built;
|
||||
const before = panorama.listenerCount();
|
||||
panorama.setPano = (id) => {
|
||||
panorama.pano = id; // Google never answers
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(OPEN_TIMEOUT_MS);
|
||||
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
|
||||
assert.equal(panorama.listenerCount(), before);
|
||||
assert.equal(loader.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
|
||||
let now = 1_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
let failures = 2;
|
||||
let calls = 0;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
calls++;
|
||||
if (failures-- > 0) return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
|
||||
assert.equal(calls, 1);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
for (let heading = 0; heading < 20; heading++) {
|
||||
fake.built[0].setPov({ heading, pitch: 0 });
|
||||
await settle();
|
||||
}
|
||||
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
|
||||
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
|
||||
now += DETAILS_RETRY_COOLDOWN_MS - 1;
|
||||
fake.built[0].setPov({ heading: 20, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
|
||||
now++;
|
||||
fake.built[0].setPov({ heading: 21, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 2, 'one retry at the deadline');
|
||||
}
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
now += DETAILS_RETRY_COOLDOWN_MS;
|
||||
fake.built[0].setPov({ heading: 10, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, 3, 'successful details stay cached');
|
||||
});
|
||||
|
||||
test('a failed panorama does not delay details for another panorama', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 1_000);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
fake.built[0].setPano('pano-b');
|
||||
await settle();
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
fake.built[0].setPano('pano-a');
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
['pano-a', 'pano-b'],
|
||||
'returning respects the pause',
|
||||
);
|
||||
});
|
||||
|
||||
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await assert.rejects(viewer.open('gone'), /no imagery/);
|
||||
// The new panorama moves first; its OK status follows.
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
});
|
||||
|
||||
test('a details lookup that never answers times out, cools down and retries', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let hang = true;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await flush();
|
||||
await opening;
|
||||
assert.deepEqual(requests, ['pano-a']);
|
||||
fake.built[0].setPov({ heading: 5, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
|
||||
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
|
||||
await flush();
|
||||
hang = false;
|
||||
fake.built[0].setPov({ heading: 6, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
|
||||
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
|
||||
fake.built[0].setPov({ heading: 7, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-a']);
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
assert.equal(poses.at(-1).bearing, 7);
|
||||
});
|
||||
|
||||
test('a remount looks up again a panorama whose lookup hung', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let answerHung;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (requests.length === 1)
|
||||
return new Promise((resolve) => {
|
||||
answerHung = () => resolve(getPanorama.call(this, request));
|
||||
});
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-b');
|
||||
viewer.unmount();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-b');
|
||||
await settle();
|
||||
assert.deepEqual(requests, ['pano-b', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
// The hung answer arriving now is for a viewer that is gone: dropped.
|
||||
const count = poses.length;
|
||||
answerHung();
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
|
||||
|
||||
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
|
||||
export const MAX_TILE_SEGMENTS = 20_000;
|
||||
/** A chained line stops growing here and the next one starts. */
|
||||
export const MAX_LINE_POINTS = 5_000;
|
||||
|
||||
/**
|
||||
* The way segments of a KartaView coverage tile
|
||||
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
|
||||
* carry no sequence, date or projection: a filter thins them through a mask
|
||||
* (see `maskedSegments`).
|
||||
* @param {object} payload
|
||||
* @returns {Array<Array<[number, number]>>}
|
||||
*/
|
||||
export function tileSegments(payload) {
|
||||
// Not cached as empty: an unreadable tile is asked for again.
|
||||
if (!Array.isArray(payload?.lines))
|
||||
throw new Error('KartaView sent an unreadable coverage tile');
|
||||
const segments = [];
|
||||
for (const line of payload.lines) {
|
||||
if (segments.length >= MAX_TILE_SEGMENTS) break;
|
||||
const points = [];
|
||||
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
||||
const lon = Number(point?.lng);
|
||||
const lat = Number(point?.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const last = points[points.length - 1];
|
||||
if (last && last[0] === lon && last[1] === lat) continue;
|
||||
points.push([lon, lat]);
|
||||
}
|
||||
if (points.length >= 2) segments.push(points);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments chained into line records for the shared coverage: segments
|
||||
* that meet end to start are joined, which cuts the line count about
|
||||
* fourfold.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
*/
|
||||
export function chainSegments(segments, { x, y, z }) {
|
||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||
const startingAt = new Map();
|
||||
segments.forEach((segment, index) => {
|
||||
const at = key(segment[0]);
|
||||
const list = startingAt.get(at);
|
||||
if (list) list.push(index);
|
||||
else startingAt.set(at, [index]);
|
||||
});
|
||||
const used = new Uint8Array(segments.length);
|
||||
// Per start point, how far its list has been used: each segment is looked
|
||||
// at once, so a tile of segments sharing one point stays linear.
|
||||
const cursor = new Map();
|
||||
const nextFrom = (point) => {
|
||||
const at = key(point);
|
||||
const list = startingAt.get(at);
|
||||
if (!list) return undefined;
|
||||
let i = cursor.get(at) ?? 0;
|
||||
while (i < list.length && used[list[i]]) i++;
|
||||
cursor.set(at, i);
|
||||
return list[i];
|
||||
};
|
||||
const sequences = [];
|
||||
segments.forEach((segment, index) => {
|
||||
if (used[index]) return;
|
||||
used[index] = 1;
|
||||
const chain = [...segment];
|
||||
for (;;) {
|
||||
const next = nextFrom(chain[chain.length - 1]);
|
||||
if (next === undefined) break;
|
||||
// Full: the segment starts the next line instead.
|
||||
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
|
||||
used[next] = 1;
|
||||
chain.push(...segments[next].slice(1));
|
||||
}
|
||||
sequences.push({
|
||||
id: `${z}/${x}/${y}/${sequences.length}`,
|
||||
parts: [chain],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
});
|
||||
});
|
||||
return { sequences, overview: [] };
|
||||
}
|
||||
|
||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||
export function decodeCoverageTile(payload, address) {
|
||||
return chainSegments(tileSegments(payload), address);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segments a filtered PNG tile of the same address draws over. The PNG
|
||||
* draws the same ways a few pixels wide (on a transparent ground): a
|
||||
* segment is kept when at least half the points sampled along it have a
|
||||
* drawn pixel within one pixel. Segments either lie on it or miss it
|
||||
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
|
||||
* between). A segment with no point on the tile is not this tile's to keep.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
*/
|
||||
export function maskedSegments(segments, mask, { x, y, z }) {
|
||||
const { width, height, alpha } = mask;
|
||||
const n = 2 ** z;
|
||||
const toPixel = ([lon, lat]) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
|
||||
};
|
||||
const drawnNear = (px, py) => {
|
||||
const cx = Math.floor(px);
|
||||
const cy = Math.floor(py);
|
||||
for (let dy = -1; dy <= 1; dy++)
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const X = cx + dx;
|
||||
const Y = cy + dy;
|
||||
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
|
||||
if (alpha[Y * width + X] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return segments.filter((segment) => {
|
||||
let on = 0;
|
||||
let tested = 0;
|
||||
let [ax, ay] = toPixel(segment[0]);
|
||||
for (let i = 1; i < segment.length; i++) {
|
||||
const [bx, by] = toPixel(segment[i]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
|
||||
);
|
||||
// Each point once: a segment's next stretch starts where this one ends.
|
||||
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
|
||||
const px = ax + ((bx - ax) * j) / steps;
|
||||
const py = ay + ((by - ay) * j) / steps;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
tested++;
|
||||
if (drawnNear(px, py)) on++;
|
||||
}
|
||||
ax = bx;
|
||||
ay = by;
|
||||
}
|
||||
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
|
||||
* where the browser can.
|
||||
* @param {Uint8Array} bytes the PNG
|
||||
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
|
||||
*/
|
||||
export async function readMaskImage(bytes) {
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
);
|
||||
try {
|
||||
const { width, height } = bitmap;
|
||||
const canvas =
|
||||
typeof OffscreenCanvas === 'function'
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, width, height).data;
|
||||
const alpha = new Uint8Array(width * height);
|
||||
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
|
||||
return { width, height, alpha };
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
MAX_LINE_POINTS,
|
||||
MAX_TILE_SEGMENTS,
|
||||
chainSegments,
|
||||
decodeCoverageTile,
|
||||
maskedSegments,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
|
||||
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
||||
const TILE = { x: 4412, y: 6061, z: 14 };
|
||||
|
||||
test('segments that meet end to start chain into one line', () => {
|
||||
const { sequences, overview } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)], matched: '1' },
|
||||
{ track: [at(5, 5), at(6, 6)], matched: '1' },
|
||||
{ track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' },
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
assert.deepEqual(overview, []);
|
||||
assert.equal(sequences.length, 2);
|
||||
assert.deepEqual(sequences[0].parts, [
|
||||
[
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[3, 1],
|
||||
[4, 2],
|
||||
],
|
||||
]);
|
||||
assert.equal(sequences[0].id, '14/4412/6061/0');
|
||||
assert.equal(sequences[1].id, '14/4412/6061/1');
|
||||
// Nothing to date or project: every line passes as flat and undated.
|
||||
assert.equal(sequences[0].capturedAt, 0);
|
||||
assert.equal(sequences[0].isPano, false);
|
||||
});
|
||||
|
||||
test('a junction continues one way only, and every segment is drawn exactly once', () => {
|
||||
const { sequences } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(0, 0), at(1, 0)] },
|
||||
{ track: [at(1, 0), at(2, 0)] },
|
||||
{ track: [at(1, 0), at(1, 1)] },
|
||||
// A loop back to its own start must end, not spin.
|
||||
{ track: [at(9, 9), at(9, 8)] },
|
||||
{ track: [at(9, 8), at(9, 9)] },
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
assert.equal(sequences.length, 3);
|
||||
// 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points.
|
||||
assert.equal(points, 8);
|
||||
});
|
||||
|
||||
test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => {
|
||||
const { sequences } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] },
|
||||
{ track: [at(3, 3)] },
|
||||
{ track: null },
|
||||
null,
|
||||
],
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
assert.equal(sequences.length, 1);
|
||||
assert.deepEqual(sequences[0].parts[0], [
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]);
|
||||
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
|
||||
});
|
||||
|
||||
test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => {
|
||||
for (const payload of [
|
||||
null,
|
||||
{},
|
||||
{ lines: 'nope' },
|
||||
{ status: { apiCode: 690 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => decodeCoverageTile(payload, TILE),
|
||||
/unreadable coverage tile/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile whose segments all share one point decodes in linear time', () => {
|
||||
// [P, Q_i, P]: every chain step looks at the same start point's list.
|
||||
// 60k: the old quadratic chaining took over a second; capped and linear
|
||||
// decoding takes tens of milliseconds.
|
||||
const lines = Array.from({ length: 60_000 }, (_, i) => ({
|
||||
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
|
||||
}));
|
||||
const started = performance.now();
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const ms = performance.now() - started;
|
||||
assert.ok(
|
||||
ms < 1_000,
|
||||
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
|
||||
);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.equal(
|
||||
points,
|
||||
MAX_TILE_SEGMENTS * 2 + sequences.length,
|
||||
'every segment drawn once',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile is capped in segments and in points per line', () => {
|
||||
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
|
||||
track: [at(i, 0), at(i + 1, 0)],
|
||||
}));
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
// One long chain, cut into lines of at most MAX_LINE_POINTS.
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
|
||||
assert.ok(
|
||||
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
|
||||
);
|
||||
});
|
||||
|
||||
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
|
||||
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
|
||||
const n = 2 ** z;
|
||||
const lon = ((x + px / size) / n) * 360 - 180;
|
||||
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
|
||||
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/** A 512 px mask drawn on the given rows. */
|
||||
function rowsMask(rows) {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
}
|
||||
|
||||
test('a mask keeps the segments it draws over, chained as before', () => {
|
||||
const along = (row, from = 40, to = 470) => [
|
||||
lonLatAt(from, row),
|
||||
lonLatAt(to, row),
|
||||
];
|
||||
const segments = [
|
||||
along(100), // drawn at row 100
|
||||
along(101.6), // a pixel off: still drawn within one pixel
|
||||
along(300), // not drawn
|
||||
// Two thirds along the drawn row, then off it: kept (at least half).
|
||||
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
|
||||
// A third along it, then off: dropped.
|
||||
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
|
||||
// Off the tile entirely: not this tile's to keep.
|
||||
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
|
||||
];
|
||||
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
|
||||
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
|
||||
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
|
||||
// A full mask keeps every segment that is on the tile.
|
||||
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
|
||||
});
|
||||
|
||||
test('decoding is segments then chaining: the same lines either way', () => {
|
||||
const payload = {
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)] },
|
||||
{ track: [at(2, 1), at(3, 1)] },
|
||||
{ track: [at(5, 5), at(6, 6)] },
|
||||
],
|
||||
};
|
||||
const segments = tileSegments(payload);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.deepEqual(
|
||||
chainSegments(segments, TILE),
|
||||
decodeCoverageTile(payload, TILE),
|
||||
);
|
||||
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
|
||||
});
|
||||
Loaded 100 of 334 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user