mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into feat/aqhi
Port to current main: main reserved layer-state token 0 for street-level, so air-quality takes the next free token, 4 (registry, ledger, and the allocation tests shift by one). Main moved source contracts into sourceComposition.js, so air-quality registers its getSnapshot contract there. Package boundaries keep main's lists plus this branch's air-quality modules and packages.
This commit is contained in:
commit
046126eb36
675 files changed
+102751
-4110
No files matched your search
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
|
||||
export * from '../../data/contactMatch.js';
|
||||
export * from '../../data/iconOrientation.js';
|
||||
export * from '../../data/layerState.js';
|
||||
export * from '../../data/layerStateCoordinator.js';
|
||||
export * from '../../data/modelVisualAnchor.js';
|
||||
export * from '../../data/motionModel.js';
|
||||
export * from '../../data/renderAltitude.js';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// setInstallationStatus is only ever called with
|
||||
// loading/zoom-in/ready/stale/empty/unavailable.
|
||||
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
|
||||
// update() both resolve as soon as the first /api/ais-live poll answers,
|
||||
// update() both resolve as soon as the first /api/vessels poll answers,
|
||||
// so the lifecycle settles to `enabled` — but until the server-side socket
|
||||
// delivers a position, firstConnectPhase is 'loading' and getStats()
|
||||
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
import {
|
||||
extractStationsArray,
|
||||
normalizeGbfsBool,
|
||||
parseStationInformation,
|
||||
parseStationStatus,
|
||||
toNonNegativeInteger,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Coerce a GBFS boolean field to a native boolean.
|
||||
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
|
||||
* @param {*} value - Raw field value from GBFS JSON.
|
||||
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function normalizeGbfsBool(value, fallback = true) {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return num !== 0;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (text === 'true' || text === 'yes') return true;
|
||||
if (text === 'false' || text === 'no') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as a non-negative integer, returning fallback on failure.
|
||||
* @param {*} value - Raw numeric value.
|
||||
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
|
||||
function toNonNegativeInteger(value, fallback = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a globally unique render key for a station.
|
||||
* @param {string} cityId - City identifier.
|
||||
@@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the stations array from a GBFS JSON payload.
|
||||
* Handles multiple response shapes: { data: { stations: [...] } },
|
||||
* { data: [...] }, and nested objects with a stations sub-key.
|
||||
* @param {Object} payload - Parsed GBFS JSON response.
|
||||
* @returns {Object[]} Array of raw station objects (may be empty).
|
||||
*/
|
||||
|
||||
function extractStationsArray(payload) {
|
||||
const data = payload?.data;
|
||||
if (Array.isArray(data?.stations)) return data.stations;
|
||||
if (Array.isArray(data)) return data;
|
||||
// Some feeds nest stations under an additional key (e.g. locale wrappers)
|
||||
if (data && typeof data === 'object') {
|
||||
for (const value of Object.values(data)) {
|
||||
if (Array.isArray(value?.stations)) return value.stations;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
|
||||
* invalid coordinates are silently skipped.
|
||||
* @param {Object} payload - Parsed station_information.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to station info objects.
|
||||
*/
|
||||
|
||||
function parseStationInformation(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS 2.x (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
continue;
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
});
|
||||
}
|
||||
|
||||
return stationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_status payload into a Map of real-time availability.
|
||||
* @param {Object} payload - Parsed station_status.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to status objects containing
|
||||
* bikesAvailable, docksAvailable, and operational flags.
|
||||
*/
|
||||
|
||||
function parseStationStatus(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const statusMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
if (!stationId) continue;
|
||||
|
||||
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
|
||||
const docks = toNonNegativeInteger(raw?.num_docks_available);
|
||||
statusMap.set(stationId, {
|
||||
stationId,
|
||||
bikesAvailable: bikes,
|
||||
docksAvailable: docks,
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
lastReported: toNonNegativeInteger(raw?.last_reported),
|
||||
});
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
return {
|
||||
normalizeGbfsBool,
|
||||
toNonNegativeInteger,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
|
||||
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
|
||||
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
|
||||
|
||||
const STATION_INFORMATION = {
|
||||
data: {
|
||||
@@ -204,6 +205,101 @@ test('the proxy relays its cache policy and upstream host through the mounted ro
|
||||
);
|
||||
});
|
||||
|
||||
test('the Norwegian city-bike systems are served through the mounted route', async () => {
|
||||
for (const [id, systemId] of [
|
||||
['oslo-bysykkel', 'oslobysykkel.no'],
|
||||
['bergen-bysykkel', 'bergenbysykkel.no'],
|
||||
['trondheim-bysykkel', 'trondheimbysykkel.no'],
|
||||
]) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
assert.ok(system, `${id} is registered`);
|
||||
assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']);
|
||||
assert.equal(
|
||||
system.stationStatusUrl,
|
||||
`https://gbfs.urbansharing.com/${systemId}/station_status.json`,
|
||||
);
|
||||
}
|
||||
|
||||
await withUpstream(
|
||||
{
|
||||
[OSLO.stationInformationUrl]: STATION_INFORMATION,
|
||||
[OSLO.stationStatusUrl]: STATION_STATUS,
|
||||
},
|
||||
async (requested) => {
|
||||
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
|
||||
const info = await source.getStations(OSLO.stationInformationUrl);
|
||||
const status = await source.getStations(OSLO.stationStatusUrl);
|
||||
assert.deepEqual(requested, [
|
||||
OSLO.stationInformationUrl,
|
||||
OSLO.stationStatusUrl,
|
||||
]);
|
||||
assert.equal(info.data.stations.length, 1);
|
||||
assert.equal(status.data.stations.length, 1);
|
||||
|
||||
const response = await mountGbfsProxy()(
|
||||
'/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
response.headers.get('x-gbfs-upstream'),
|
||||
'gbfs.urbansharing.com',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => {
|
||||
const expected = {
|
||||
'stavanger-kolumbus':
|
||||
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
|
||||
'skien-farte':
|
||||
'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status',
|
||||
'lillestrom-bysykkel':
|
||||
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
|
||||
};
|
||||
const upstream = {};
|
||||
for (const [id, statusUrl] of Object.entries(expected)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
assert.ok(system, `${id} is registered`);
|
||||
assert.equal(system.stationStatusUrl, statusUrl);
|
||||
upstream[system.stationInformationUrl] = STATION_INFORMATION;
|
||||
upstream[system.stationStatusUrl] = STATION_STATUS;
|
||||
}
|
||||
|
||||
await withUpstream(upstream, async (requested) => {
|
||||
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
|
||||
for (const id of Object.keys(expected)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
const info = await source.getStations(system.stationInformationUrl);
|
||||
const status = await source.getStations(system.stationStatusUrl);
|
||||
assert.equal(info.data.stations.length, 1, `${id} information`);
|
||||
assert.equal(status.data.stations.length, 1, `${id} status`);
|
||||
}
|
||||
assert.equal(requested.length, 6);
|
||||
|
||||
// Entur's extensionless station_information keeps its 5-minute cache.
|
||||
const kolumbus = CITY_BY_ID.get('stavanger-kolumbus');
|
||||
const response = await mountGbfsProxy()(
|
||||
'/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl),
|
||||
);
|
||||
assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
|
||||
});
|
||||
});
|
||||
|
||||
test('the mounted route keeps multi-API hosts to their GBFS tree', async () => {
|
||||
await withUpstream({}, async (requested) => {
|
||||
const mounted = mountGbfsProxy();
|
||||
for (const target of [
|
||||
'https://api.entur.io/journey-planner/v3/station_status',
|
||||
'https://api.cyclocity.fr/other/station_status.json',
|
||||
]) {
|
||||
const response = await mounted('/api/gbfs/' + encodeURIComponent(target));
|
||||
assert.equal(response.status, 400, target);
|
||||
}
|
||||
assert.deepEqual(requested, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('the query-string shape the client used to send is refused by the mounted route', async () => {
|
||||
await withUpstream(
|
||||
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
|
||||
|
||||
@@ -23,6 +23,47 @@ function bcycleEntry({
|
||||
...buildBcycleUrls(systemId),
|
||||
};
|
||||
}
|
||||
function urbanSharingEntry({
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
systemId,
|
||||
provider,
|
||||
loadRadiusKm = 100,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
loadRadiusKm,
|
||||
provider,
|
||||
stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`,
|
||||
stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`,
|
||||
};
|
||||
}
|
||||
function enturEntry({
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
systemId,
|
||||
provider,
|
||||
loadRadiusKm = 100,
|
||||
}) {
|
||||
const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`;
|
||||
return {
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
loadRadiusKm,
|
||||
provider,
|
||||
stationInformationUrl: `${base}/station_information`,
|
||||
stationStatusUrl: `${base}/station_status`,
|
||||
};
|
||||
}
|
||||
const RAW_GBFS_CITY_REGISTRY = [
|
||||
{
|
||||
id: 'nyc-citibike',
|
||||
@@ -48,6 +89,56 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json',
|
||||
provider: 'Divvy',
|
||||
},
|
||||
{
|
||||
id: 'detroit-mogo',
|
||||
city: 'Detroit, MI',
|
||||
centerLat: 42.3314,
|
||||
centerLon: -83.0458,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'MoGo',
|
||||
},
|
||||
{
|
||||
id: 'pittsburgh-pogoh',
|
||||
city: 'Pittsburgh, PA',
|
||||
centerLat: 40.4406,
|
||||
centerLon: -79.9959,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'POGOH',
|
||||
},
|
||||
{
|
||||
id: 'aspen-we-cycle',
|
||||
city: 'Aspen, CO',
|
||||
centerLat: 39.1911,
|
||||
centerLon: -106.8175,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'WE-cycle',
|
||||
},
|
||||
// HIBike serves both Kailua-Kona and Hilo, about 95 km apart; the centre sits
|
||||
// between them so a camera over either town loads the whole system.
|
||||
{
|
||||
id: 'hawaii-island-hibike',
|
||||
city: 'Kona & Hilo, HI',
|
||||
centerLat: 19.68,
|
||||
centerLon: -155.54,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'HIBike',
|
||||
},
|
||||
{
|
||||
id: 'dc-capital-bikeshare',
|
||||
city: 'Washington, DC',
|
||||
@@ -318,6 +409,59 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
centerLon: -119.6982,
|
||||
systemId: 'bcycle_santabarbara',
|
||||
}),
|
||||
urbanSharingEntry({
|
||||
id: 'oslo-bysykkel',
|
||||
city: 'Oslo, Norway',
|
||||
centerLat: 59.9139,
|
||||
centerLon: 10.7522,
|
||||
systemId: 'oslobysykkel.no',
|
||||
provider: 'Oslo Bysykkel',
|
||||
}),
|
||||
urbanSharingEntry({
|
||||
id: 'bergen-bysykkel',
|
||||
city: 'Bergen, Norway',
|
||||
centerLat: 60.3913,
|
||||
centerLon: 5.3221,
|
||||
systemId: 'bergenbysykkel.no',
|
||||
provider: 'Bergen Bysykkel',
|
||||
}),
|
||||
urbanSharingEntry({
|
||||
id: 'trondheim-bysykkel',
|
||||
city: 'Trondheim, Norway',
|
||||
centerLat: 63.4305,
|
||||
centerLon: 10.3951,
|
||||
systemId: 'trondheimbysykkel.no',
|
||||
provider: 'Trondheim Bysykkel',
|
||||
}),
|
||||
enturEntry({
|
||||
id: 'stavanger-kolumbus',
|
||||
city: 'Stavanger, Norway',
|
||||
centerLat: 58.9,
|
||||
centerLon: 5.72,
|
||||
systemId: 'kolumbusbysykkel',
|
||||
provider: 'Kolumbus Bysykkel',
|
||||
}),
|
||||
enturEntry({
|
||||
id: 'skien-farte',
|
||||
city: 'Skien/Porsgrunn, Norway',
|
||||
centerLat: 59.16,
|
||||
centerLon: 9.64,
|
||||
systemId: 'fartebysykkel',
|
||||
provider: 'Farte Bysykkel',
|
||||
loadRadiusKm: 40,
|
||||
}),
|
||||
{
|
||||
id: 'lillestrom-bysykkel',
|
||||
city: 'Lillestrøm, Norway',
|
||||
centerLat: 59.9556,
|
||||
centerLon: 11.0492,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
|
||||
provider: 'Bysykkel Lillestrøm',
|
||||
},
|
||||
];
|
||||
function normalizeRegistryEntry(entry) {
|
||||
const id = String(entry?.id || '')
|
||||
@@ -346,11 +490,13 @@ function normalizeRegistryEntry(entry) {
|
||||
) {
|
||||
throw new Error(`GBFS entry "${id}" must use https URLs`);
|
||||
}
|
||||
// Validate that URLs end with expected GBFS endpoint filenames
|
||||
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
|
||||
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
|
||||
if (
|
||||
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
|
||||
) {
|
||||
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
|
||||
}
|
||||
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
|
||||
if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
|
||||
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each city's PBSC host, and the stations farthest from its registered centre
|
||||
// in the live feed (checked 2026-10-13). HIBike's centre sits between its two
|
||||
// towns, so it lists the outermost station in each.
|
||||
const CITIES = {
|
||||
'detroit-mogo': {
|
||||
host: 'detroit.publicbikesystem.net',
|
||||
provider: 'MoGo',
|
||||
stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }],
|
||||
},
|
||||
'pittsburgh-pogoh': {
|
||||
host: 'pittsburgh.publicbikesystem.net',
|
||||
provider: 'POGOH',
|
||||
stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }],
|
||||
},
|
||||
'aspen-we-cycle': {
|
||||
host: 'aspen.publicbikesystem.net',
|
||||
provider: 'WE-cycle',
|
||||
stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }],
|
||||
},
|
||||
'hawaii-island-hibike': {
|
||||
host: 'kona.publicbikesystem.net',
|
||||
provider: 'HIBike',
|
||||
stations: [
|
||||
{ name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 },
|
||||
{ name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
test('the added US PBSC systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
assert.ok(city, `${id} is registered`);
|
||||
assert.equal(city.provider, expected.provider);
|
||||
assert.deepEqual(city.hosts, [expected.host]);
|
||||
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('HIBike stays inactive away from Hawaii Island', () => {
|
||||
// Honolulu is about 270 km from the HIBike centre.
|
||||
assert.equal(
|
||||
computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -51,5 +51,18 @@ export function createCctvSource({
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
/** Read one current frame through the registered frame endpoint. */
|
||||
async getFrame(camera, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(frameUrlFor(camera), {
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and
|
||||
assert.equal(a.getStats().count, 0);
|
||||
assert.equal(b.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('frames are read through the registered frame endpoint', async () => {
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const requested = [];
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
requested.push({ url, cache: init.cache });
|
||||
return new Response(png, {
|
||||
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const frame = await source.getFrame(camera);
|
||||
assert.equal(frame.contentType, 'image/png');
|
||||
assert.deepEqual([...frame.bytes], [...png]);
|
||||
assert.equal(
|
||||
requested[0].url.split('?')[0],
|
||||
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
|
||||
);
|
||||
assert.equal(requested[0].cache, 'no-store');
|
||||
const failing = createCctvSource({
|
||||
fetchImpl: async () => new Response(null, { status: 502 }),
|
||||
});
|
||||
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
|
||||
});
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
|
||||
* contexts, so a page opened over plain HTTP from another machine (the
|
||||
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
|
||||
* and yields the same shape the server's lease check accepts.
|
||||
*/
|
||||
export function createLeaseId(cryptoImpl = globalThis.crypto) {
|
||||
if (typeof cryptoImpl.randomUUID === 'function') {
|
||||
return cryptoImpl.randomUUID();
|
||||
}
|
||||
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
|
||||
return [
|
||||
hex.slice(0, 4),
|
||||
hex.slice(4, 6),
|
||||
hex.slice(6, 8),
|
||||
hex.slice(8, 10),
|
||||
hex.slice(10),
|
||||
]
|
||||
.map((group) => group.join(''))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/** One decoder per active camera; both surfaces consume this video element. */
|
||||
export function attachCctvVideo(
|
||||
video,
|
||||
@@ -7,13 +32,14 @@ export function attachCctvVideo(
|
||||
loadHls = () => import('hls.js'),
|
||||
onFailure = () => {},
|
||||
fetchImpl = globalThis.fetch,
|
||||
cryptoImpl = globalThis.crypto,
|
||||
} = {},
|
||||
) {
|
||||
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
||||
video.loop = feedType !== 'hls';
|
||||
let disposed = false;
|
||||
let hls = null;
|
||||
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
||||
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
|
||||
const mediaUrl =
|
||||
feedType === 'hls'
|
||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
|
||||
|
||||
const LEASE_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
/** What an insecure context exposes: getRandomValues but no randomUUID. */
|
||||
const insecureCrypto = {
|
||||
getRandomValues: (array) => webcrypto.getRandomValues(array),
|
||||
};
|
||||
function video() {
|
||||
const v = new EventTarget();
|
||||
Object.assign(v, {
|
||||
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
|
||||
assert.equal(releases[0].init.method, 'DELETE');
|
||||
});
|
||||
|
||||
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
|
||||
assert.match(createLeaseId(), LEASE_V4);
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
|
||||
assert.equal(ids.size, 64);
|
||||
for (const id of ids) assert.match(id, LEASE_V4);
|
||||
});
|
||||
|
||||
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
|
||||
const source = video();
|
||||
source.canPlayType = () => 'probably';
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||
fetchImpl: async () => {},
|
||||
cryptoImpl: insecureCrypto,
|
||||
});
|
||||
try {
|
||||
await playback.ready;
|
||||
const lease = new URL(
|
||||
source.src,
|
||||
'http://192.168.1.10:4173/',
|
||||
).searchParams.get('lease');
|
||||
assert.match(lease, LEASE_V4);
|
||||
} finally {
|
||||
playback.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
||||
for (const feedType of ['mp4', 'webm', 'hls']) {
|
||||
const source = video();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
|
||||
|
||||
export const EMERGENCY_TINT =
|
||||
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
|
||||
|
||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||
// taxiing and landing") -----------------------------------------------------------
|
||||
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
flightState,
|
||||
@@ -167,6 +168,10 @@ export function createQueries({
|
||||
flightState.records.missingPolls.get(icao24) ||
|
||||
flightState.feed._backoff,
|
||||
),
|
||||
// This poll's transponder broadcast (not sticky; see records.js).
|
||||
squawk: info?.squawk ?? null,
|
||||
emergency: info?.emergency ? { ...info.emergency } : null,
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
airline: info?.airline ?? null,
|
||||
// CLASS label follows the TR-3B conversion so every downstream card
|
||||
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
|
||||
@@ -219,7 +224,10 @@ export function createQueries({
|
||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||
* military: boolean, aircraftClass: string|null, originCountry: string|null,
|
||||
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
|
||||
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
|
||||
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null,
|
||||
* routeDestination: string|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(
|
||||
@@ -253,6 +261,20 @@ export function createQueries({
|
||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||
originCountry: text(info?.originCountry),
|
||||
operator: text(info?.airline),
|
||||
squawk: text(info?.squawk),
|
||||
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||
// without the flag.
|
||||
emergency: info?.emergency?.severity === 'emergency',
|
||||
emergencyKind: text(info?.emergency?.kind),
|
||||
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||
emergencySource: text(info?.emergency?.source),
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
// Latest known general aircraft-message contact, retained if a poll
|
||||
// has no time. This is not the receipt time of the current status.
|
||||
lastContactAtMs: num(info?.lastContactEpochMs),
|
||||
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
||||
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
pickRenderAltitudeM,
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
approxDistanceKm,
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
@@ -226,6 +227,22 @@ export class FlightRecords {
|
||||
// like callsign so a transient blank row doesn't blank the field.
|
||||
originCountry:
|
||||
stickyText(origin_country, prevMeta?.originCountry) || null,
|
||||
// Per poll, NOT sticky: an emergency code clears when the crew resets
|
||||
// it, so a blank squawk must not hold a stale 7700 on screen.
|
||||
squawk: observation.squawk ?? null,
|
||||
// One source-switch omission may retain an ADS-B status, while an
|
||||
// explicit clear or changed squawk always takes effect immediately.
|
||||
emergency:
|
||||
observation.emergency === undefined &&
|
||||
observation.squawk === prevMeta?.squawk &&
|
||||
prevMeta?.emergency?.source === 'ads-b' &&
|
||||
(prevMeta?.missingEmergencyPolls ?? 0) < 1
|
||||
? prevMeta.emergency
|
||||
: aircraftEmergency(observation),
|
||||
missingEmergencyPolls:
|
||||
observation.emergency === undefined
|
||||
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
|
||||
: 0,
|
||||
// OpenSky distinguishes the last position epoch from the last
|
||||
// transponder message. The fleet coast horizon uses this actual
|
||||
// contact time so a temporarily old position does not hard-freeze
|
||||
|
||||
@@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col
|
||||
assert.equal(next.meta.renderAltitudeM, 1040);
|
||||
assert.equal(next.meta.wasAirborne, true);
|
||||
});
|
||||
|
||||
test('civil emergency status follows the latest poll instead of sticking', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation({ squawk: '7700' }), view());
|
||||
assert.equal(first.meta.squawk, '7700');
|
||||
assert.equal(first.meta.emergency.kind, 'general');
|
||||
const next = store.receive(observation({ squawk: '2000' }), view());
|
||||
assert.equal(next.meta.squawk, '2000');
|
||||
assert.equal(next.meta.emergency, null);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
EMERGENCY_TINT,
|
||||
GROUND_SCALE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
@@ -54,13 +56,34 @@ export function createRendering({
|
||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
||||
services.recession;
|
||||
|
||||
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
|
||||
/** True while this contact's latest poll broadcast an emergency code. */
|
||||
|
||||
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
|
||||
return isEmergency(info?.emergency);
|
||||
}
|
||||
|
||||
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
|
||||
* amber for known-military, white otherwise.
|
||||
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
||||
|
||||
function _fleetBillboardColor(icao24) {
|
||||
function _fleetBillboardColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
}
|
||||
|
||||
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
|
||||
|
||||
function _cockpitContactColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
|
||||
}
|
||||
|
||||
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||
|
||||
function _fleetBillboardScale(icao24, klass) {
|
||||
@@ -129,9 +152,7 @@ export function createRendering({
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.scale = limbScale;
|
||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||
bb.color = (
|
||||
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
|
||||
).withAlpha(freshnessAlpha);
|
||||
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
|
||||
bb.rotation = 0;
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +183,7 @@ export function createRendering({
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -1009,10 +1030,8 @@ export function createRendering({
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? isMilitaryIcao(icao24)
|
||||
? MIL_TINT
|
||||
: COCKPIT_CIVILIAN_COLOR
|
||||
: _fleetBillboardColor(icao24);
|
||||
? _cockpitContactColor(icao24, info)
|
||||
: _fleetBillboardColor(icao24, info);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GNSS_LEVEL_COLORS,
|
||||
GNSS_MIN_AIRCRAFT,
|
||||
GNSS_WINDOW_MS,
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
gnssProvenance,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
export * from './records.js';
|
||||
export { createAdsbGnssSource } from './source.js';
|
||||
|
||||
/**
|
||||
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
|
||||
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
|
||||
* This fill participates only in rendering, so sibling polygons remain pickable.
|
||||
*/
|
||||
class GnssGroundFill extends Cesium.GroundPrimitive {
|
||||
update(frameState) {
|
||||
if (!frameState.passes.pick) super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Low stays visible over land so coverage reads apart from no data. */
|
||||
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
|
||||
const LEVEL_LABELS = Object.freeze({
|
||||
low: 'Low-accuracy score < 2%',
|
||||
medium: 'Low-accuracy score 2–10%',
|
||||
high: 'Low-accuracy score > 10%',
|
||||
});
|
||||
|
||||
/** Degrees at the centre of the current view, or null before the camera settles. */
|
||||
export function defaultGnssViewAnchor(viewer) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
|
||||
// At globe scale the visible rectangle can be the whole world, whose
|
||||
// midpoint is unrelated to the place under the screen centre.
|
||||
const centre =
|
||||
canvas?.clientWidth > 0 &&
|
||||
canvas.clientHeight > 0 &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
);
|
||||
const rectangle =
|
||||
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
|
||||
const carto = centre
|
||||
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
|
||||
: rectangle
|
||||
? Cesium.Rectangle.center(rectangle)
|
||||
: viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
||||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
||||
return Number.isFinite(latitude) && Number.isFinite(longitude)
|
||||
? { latitude, longitude }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Own one GNSS navigation-integrity display: rolling integrity observations
|
||||
* around the views the user visits, binned into cells coloured by the
|
||||
* gpsjam low-accuracy score. Share links retain token 3.
|
||||
*/
|
||||
export function createGnssIntegrityLayer({
|
||||
source,
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
now = () => Date.now(),
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('GNSS integrity layer requires a snapshot source');
|
||||
let _viewer = null;
|
||||
let _primitives = null;
|
||||
let _request = null;
|
||||
let _enabled = false;
|
||||
let _cells = [];
|
||||
let _signature = null;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _stale = false;
|
||||
let _manager = null;
|
||||
let _cameraTimer = null;
|
||||
let _backgroundRefresh = null;
|
||||
let _lastQueryKey = null;
|
||||
let _lastRequestAt = -Infinity;
|
||||
let _removeCameraEnd = null;
|
||||
let _removeCameraChanged = null;
|
||||
let _coverage = null;
|
||||
let _retryTimer = null;
|
||||
let _retryAt = 0;
|
||||
let _failureCount = 0;
|
||||
const _observations = new Map();
|
||||
const provenance = gnssProvenance({ windowMs });
|
||||
|
||||
const queryKey = (anchor) =>
|
||||
anchor
|
||||
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
|
||||
: null;
|
||||
|
||||
function scheduleCameraRefresh() {
|
||||
if (!_enabled || !_primitives) return;
|
||||
if (now() < _retryAt) return;
|
||||
const key = queryKey(viewAnchor(_viewer));
|
||||
if (!key || key === _lastQueryKey) return;
|
||||
clearTimeout(_cameraTimer);
|
||||
// Debounce drags and bound moving/follow cameras to at most 24 requests
|
||||
// per minute. Nearby views share the proxy's whole-degree cache key.
|
||||
_cameraTimer = setTimeout(
|
||||
async () => {
|
||||
_cameraTimer = null;
|
||||
if (!_enabled || _backgroundRefresh || _request) return;
|
||||
const key = queryKey(viewAnchor(_viewer));
|
||||
if (!key || key === _lastQueryKey) return;
|
||||
await refreshInBackground();
|
||||
},
|
||||
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshInBackground() {
|
||||
if (!_enabled || _backgroundRefresh) return;
|
||||
const controller = new AbortController();
|
||||
_backgroundRefresh = controller;
|
||||
try {
|
||||
if (_manager)
|
||||
await _manager.refreshLayer(layer.id, { signal: controller.signal });
|
||||
else await layer.update(undefined, { signal: controller.signal });
|
||||
} finally {
|
||||
if (_backgroundRefresh === controller) {
|
||||
_backgroundRefresh = null;
|
||||
scheduleCameraRefresh();
|
||||
scheduleRetry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
clearTimeout(_retryTimer);
|
||||
_retryTimer = null;
|
||||
if (!_enabled || !_retryAt) return;
|
||||
// Long provider cooldowns keep their full deadline. Browser timers cannot
|
||||
// exceed ~24 days, so wake in bounded chunks without fetching early.
|
||||
_retryTimer = setTimeout(
|
||||
() => {
|
||||
_retryTimer = null;
|
||||
void refreshInBackground();
|
||||
},
|
||||
Math.min(2147483647, Math.max(0, _retryAt - now())),
|
||||
);
|
||||
_retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
/** Age the store without new evidence, so expired cells leave the globe. */
|
||||
function expire() {
|
||||
pruneGnssObservations(_observations, now(), { windowMs });
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Only the id and band change what is drawn; counts alone do not.
|
||||
const signature = JSON.stringify(
|
||||
_cells.map(({ id, level }) => [id, level]),
|
||||
);
|
||||
if (signature === _signature) return;
|
||||
_signature = signature;
|
||||
_primitives.removeAll();
|
||||
if (_cells.length) {
|
||||
_primitives.add(
|
||||
new GnssGroundFill({
|
||||
geometryInstances: _cells.map(
|
||||
(cell) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: `${layer.id}:${cell.id}`,
|
||||
geometry: new Cesium.RectangleGeometry({
|
||||
rectangle: Cesium.Rectangle.fromDegrees(
|
||||
cell.west,
|
||||
cell.south,
|
||||
cell.east,
|
||||
cell.north,
|
||||
),
|
||||
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
Cesium.Color.fromCssColorString(
|
||||
GNSS_LEVEL_COLORS[cell.level],
|
||||
).withAlpha(FILL_ALPHA[cell.level]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
|
||||
releaseGeometryInstances: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: 'gnss-integrity',
|
||||
name: 'GNSS Integrity',
|
||||
icon: '📡',
|
||||
source: 'adsb.lol',
|
||||
/** Where each part of the method comes from; see records.js. */
|
||||
provenance,
|
||||
updateInterval: 60000,
|
||||
|
||||
attachDataManager(manager) {
|
||||
_manager = manager;
|
||||
},
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
throw new Error('GNSS integrity layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_primitives = new Cesium.PrimitiveCollection();
|
||||
_primitives.id = layer.id;
|
||||
_primitives.show = false;
|
||||
viewer.scene.groundPrimitives.add(_primitives);
|
||||
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
|
||||
scheduleCameraRefresh,
|
||||
);
|
||||
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
|
||||
scheduleCameraRefresh,
|
||||
);
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
// Cells left from before a long disable must not reappear past the window.
|
||||
if (_primitives) {
|
||||
expire();
|
||||
_primitives.show = true;
|
||||
}
|
||||
},
|
||||
|
||||
disable() {
|
||||
clearTimeout(_cameraTimer);
|
||||
_cameraTimer = null;
|
||||
clearTimeout(_retryTimer);
|
||||
_retryTimer = null;
|
||||
_backgroundRefresh?.abort();
|
||||
_backgroundRefresh = null;
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_primitives) _primitives.show = false;
|
||||
},
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || !_primitives || signal?.aborted) return false;
|
||||
if (now() < _retryAt) {
|
||||
expire();
|
||||
scheduleRetry();
|
||||
return true;
|
||||
}
|
||||
_retryAt = 0;
|
||||
clearTimeout(_retryTimer);
|
||||
_retryTimer = null;
|
||||
const anchor = viewAnchor(_viewer);
|
||||
// No anchor yet (camera still settling) is not a failure: returning
|
||||
// false would make the manager reject the enable. The next interval
|
||||
// retries; cells already drawn still age out of the window.
|
||||
if (!anchor) {
|
||||
expire();
|
||||
return true;
|
||||
}
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
const onAbort = () => request.abort(signal.reason);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
_lastQueryKey = queryKey(anchor);
|
||||
_lastRequestAt = monotonicNow();
|
||||
_coverage = {
|
||||
kind: 'regional',
|
||||
latitude: Math.round(anchor.latitude),
|
||||
longitude: Math.round(anchor.longitude),
|
||||
radiusNm: 250,
|
||||
};
|
||||
let received = false;
|
||||
try {
|
||||
const snapshot = await source.getSnapshot(anchor, {
|
||||
signal: request.signal,
|
||||
});
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
received = true;
|
||||
// Date the rows by when the proxy observed them: a cached or stale
|
||||
// replay keeps its original age instead of restarting the window.
|
||||
// The proxy reports that age, so client and server clocks never mix.
|
||||
const at = now();
|
||||
accumulateGnssObservations(_observations, snapshot.rows, at, {
|
||||
windowMs,
|
||||
observedAt: at - snapshot.ageMs,
|
||||
});
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
_stale = snapshot.stale === true;
|
||||
_lastUpdate = at - snapshot.ageMs;
|
||||
_lastError = null;
|
||||
_retryAt = 0;
|
||||
_failureCount = 0;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
// A renderer/programming failure remains a real lifecycle failure.
|
||||
// Only acquisition failures are kept enabled for recovery.
|
||||
if (received) throw e;
|
||||
console.warn('[Data:GNSS] Fetch error:', e);
|
||||
_lastError = e?.message || 'GNSS integrity source unavailable';
|
||||
// Keep the last cells only while their evidence is inside the window.
|
||||
expire();
|
||||
if (e?.retryable === false) return false;
|
||||
_failureCount = Math.min(3, _failureCount + 1);
|
||||
const delay =
|
||||
Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0
|
||||
? e.retryAfterMs
|
||||
: Math.min(120000, 30000 * 2 ** (_failureCount - 1));
|
||||
_retryAt = Math.max(
|
||||
_retryAt,
|
||||
Math.min(Number.MAX_VALUE, now() + delay),
|
||||
);
|
||||
// Enabling the display succeeded. Stats still report an unavailable
|
||||
// or degraded feed; the manager keeps its normal refresh failure lane.
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (_request === request) _request = null;
|
||||
scheduleCameraRefresh();
|
||||
scheduleRetry();
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
layer.disable();
|
||||
_removeCameraEnd?.();
|
||||
_removeCameraChanged?.();
|
||||
_removeCameraEnd = null;
|
||||
_removeCameraChanged = null;
|
||||
_observations.clear();
|
||||
_cells = [];
|
||||
_signature = null;
|
||||
if (_primitives) {
|
||||
viewer.scene.groundPrimitives.remove(_primitives);
|
||||
_primitives = null;
|
||||
}
|
||||
_viewer = null;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_coverage = null;
|
||||
_retryAt = 0;
|
||||
_failureCount = 0;
|
||||
_lastQueryKey = null;
|
||||
_manager = null;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
return {
|
||||
chips: [],
|
||||
info:
|
||||
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
|
||||
(_lastError && _retryAt
|
||||
? ` · ${_lastError} · Retrying automatically${
|
||||
_retryAt <= 8640000000000000
|
||||
? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC`
|
||||
: ' after provider cooldown'
|
||||
}`
|
||||
: ''),
|
||||
infoTitle:
|
||||
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
|
||||
legend: ['high', 'medium', 'low'].map((level, index) => ({
|
||||
label: LEVEL_LABELS[level],
|
||||
color: GNSS_LEVEL_COLORS[level],
|
||||
count: _cells.filter((cell) => cell.level === level).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`,
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _cells.length,
|
||||
levels: Object.fromEntries(
|
||||
['low', 'medium', 'high'].map((level) => [
|
||||
level,
|
||||
_cells.filter((cell) => cell.level === level).length,
|
||||
]),
|
||||
),
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
retryInSec: _retryAt
|
||||
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
|
||||
: null,
|
||||
stale: _stale,
|
||||
provenance,
|
||||
coverage: _coverage,
|
||||
// Load-bearing: without an explicit boolean, layerFeedState reads the
|
||||
// adsb.lol source name as a flights fallback (src/data/feedState.js).
|
||||
fallback: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
|
||||
import { LayerLifecycle } from '../../data/lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const row = (hex, lat, degraded) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon: 30.2,
|
||||
nic: degraded ? 0 : 8,
|
||||
nacp: degraded ? 0 : 10,
|
||||
gpsLost: false,
|
||||
degraded,
|
||||
});
|
||||
|
||||
function harness(
|
||||
getSnapshot,
|
||||
{
|
||||
anchor: initialAnchor = { latitude: 50, longitude: 30 },
|
||||
initialize = true,
|
||||
} = {},
|
||||
) {
|
||||
let anchor = initialAnchor;
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
|
||||
scene: {
|
||||
globe: { show: true },
|
||||
groundPrimitives: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
value.destroy();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let clock = 1_000;
|
||||
const anchors = [];
|
||||
const layer = createGnssIntegrityLayer({
|
||||
// The real source refuses an undated snapshot; stubs default to a fresh one.
|
||||
source: {
|
||||
async getSnapshot(...args) {
|
||||
const snapshot = await getSnapshot(...args);
|
||||
return { ageMs: 0, ...snapshot };
|
||||
},
|
||||
},
|
||||
viewAnchor: () => {
|
||||
anchors.push(anchor);
|
||||
return anchor;
|
||||
},
|
||||
now: () => clock,
|
||||
monotonicNow: () => clock,
|
||||
});
|
||||
if (initialize) layer.init(viewer);
|
||||
return {
|
||||
layer,
|
||||
sources,
|
||||
anchors,
|
||||
viewer,
|
||||
advance(ms) {
|
||||
clock += ms;
|
||||
},
|
||||
now: () => clock,
|
||||
setAnchor(value) {
|
||||
anchor = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
const threeHealthy = () => [
|
||||
row('a1', 1.1, false),
|
||||
row('a2', 1.2, false),
|
||||
row('a3', 1.3, false),
|
||||
];
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
|
||||
const anchor = defaultGnssViewAnchor({
|
||||
scene: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 800 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
camera: {
|
||||
pickEllipsoid(pixel) {
|
||||
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
|
||||
return Cesium.Cartesian3.fromDegrees(-97, 30);
|
||||
},
|
||||
computeViewRectangle: () => {
|
||||
throw new Error('centre hit must not compute a rectangle');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
|
||||
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
|
||||
});
|
||||
|
||||
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
let calls = 0;
|
||||
const h = harness(
|
||||
async () => {
|
||||
calls++;
|
||||
return { rows: threeHealthy() };
|
||||
},
|
||||
{ initialize: false },
|
||||
);
|
||||
const manager = new LayerLifecycle(h.viewer);
|
||||
manager.register(h.layer);
|
||||
h.layer.attachDataManager(manager);
|
||||
const changes = [];
|
||||
manager.subscribe((change) => changes.push(change.type));
|
||||
await manager.setEnabled(h.layer.id, true);
|
||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.setAnchor({ latitude: 55, longitude: 20 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls, 2, 'one initial read and one read for the latest view');
|
||||
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
|
||||
assert.ok(changes.includes('refresh-transition'));
|
||||
assert.ok(changes.includes('refresh'));
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.deepEqual(h.layer.getStats().coverage, {
|
||||
kind: 'regional',
|
||||
latitude: 55,
|
||||
longitude: 20,
|
||||
radiusNm: 250,
|
||||
});
|
||||
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
|
||||
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(5000);
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(calls, 2, 'nearby views reuse the same query region');
|
||||
await manager.destroyAll();
|
||||
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls = [];
|
||||
let resolvePending;
|
||||
const h = harness((anchor) => {
|
||||
calls.push(anchor);
|
||||
if (calls.length === 2)
|
||||
return new Promise((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
h.setAnchor({ latitude: 35, longitude: 140 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls.length, 2, 'only one camera request in flight');
|
||||
resolvePending({ rows: threeHealthy() });
|
||||
await flush();
|
||||
h.advance(600);
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
|
||||
h.setAnchor({ latitude: 10, longitude: 10 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.layer.disable();
|
||||
h.advance(5000);
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3, 'disable drains the camera timer');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('the layer requires a snapshot source and cannot be initialized twice', () => {
|
||||
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
|
||||
const { layer } = harness(async () => ({ rows: [] }));
|
||||
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
|
||||
});
|
||||
|
||||
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
|
||||
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
|
||||
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
|
||||
assert.ok(signal instanceof AbortSignal);
|
||||
return {
|
||||
rows: [
|
||||
row('a1', 50.1, true),
|
||||
row('a2', 50.1, true),
|
||||
row('a3', 50.2, false),
|
||||
row('a4', 50.3, false),
|
||||
row('b1', 55.1, false),
|
||||
],
|
||||
stale: false,
|
||||
};
|
||||
});
|
||||
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
|
||||
assert.equal(anchors.length, 0);
|
||||
layer.enable();
|
||||
assert.equal(sources[0].show, true);
|
||||
assert.equal(await layer.update(), true);
|
||||
const entities = sources[0].get(0).geometryInstances;
|
||||
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
|
||||
assert.equal(entities[0].id, 'gnss-integrity:280:420');
|
||||
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
|
||||
assert.equal(sources[0].get(0).allowPicking, false);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.count, 1);
|
||||
assert.equal(stats.error, null);
|
||||
assert.equal(stats.fallback, false);
|
||||
const { legend } = layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
legend.map(({ count }) => count),
|
||||
[1, 0, 0],
|
||||
);
|
||||
assert.match(legend[0].blurb, /not a detection/);
|
||||
assert.match(legend[0].blurb, /need 3 aircraft/);
|
||||
});
|
||||
|
||||
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
|
||||
const { layer } = harness(async () => ({ rows: [], stale: false }));
|
||||
assert.equal(
|
||||
layer.id,
|
||||
'gnss-integrity',
|
||||
'the unpublished id describes the measured state',
|
||||
);
|
||||
assert.equal(layer.name, 'GNSS Integrity');
|
||||
assert.doesNotMatch(layer.name, /interference|jamming/i);
|
||||
const { legend } = layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
legend.map(({ label }) => label),
|
||||
[
|
||||
'Low-accuracy score > 10%',
|
||||
'Low-accuracy score 2–10%',
|
||||
'Low-accuracy score < 2%',
|
||||
],
|
||||
);
|
||||
const { blurb } = legend[0];
|
||||
assert.match(blurb, /one aircraft is discounted/);
|
||||
assert.match(blurb, /not the raw share/);
|
||||
assert.match(blurb, /not a detection/);
|
||||
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
|
||||
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
|
||||
assert.equal(layer.provenance.classifier.validated, false);
|
||||
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
|
||||
assert.deepEqual(layer.provenance.window, {
|
||||
minutes: 30,
|
||||
scope: 'visited-view',
|
||||
cellDeg: 0.5,
|
||||
minAircraft: 3,
|
||||
definedBy: 'GEV',
|
||||
});
|
||||
assert.equal(layer.provenance.interpretation.validated, false);
|
||||
assert.equal(layer.getStats().provenance, layer.provenance);
|
||||
});
|
||||
|
||||
test('a failed refresh keeps the last cells and reports the error', async () => {
|
||||
let fail = false;
|
||||
const { layer, sources } = harness(async () => {
|
||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||
return {
|
||||
rows: [
|
||||
row('a1', 1.1, false),
|
||||
row('a2', 1.2, false),
|
||||
row('a3', 1.3, false),
|
||||
],
|
||||
stale: true,
|
||||
};
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().stale, true);
|
||||
fail = true;
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('observations expire with the window, and disable aborts the request in flight', async () => {
|
||||
let pending;
|
||||
const { layer, sources, advance } = harness(
|
||||
(anchor, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pending = { resolve };
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
}),
|
||||
);
|
||||
layer.enable();
|
||||
let result = layer.update();
|
||||
pending.resolve({
|
||||
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
|
||||
});
|
||||
assert.equal(await result, true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
|
||||
advance(31 * 60_000);
|
||||
result = layer.update();
|
||||
pending.resolve({ rows: [] });
|
||||
assert.equal(await result, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
|
||||
result = layer.update();
|
||||
layer.disable();
|
||||
assert.equal(await result, false);
|
||||
assert.equal(sources[0].show, false);
|
||||
});
|
||||
|
||||
test('no view anchor means no request, and destroy releases the data source', async () => {
|
||||
let calls = 0;
|
||||
const { layer, sources } = harness(
|
||||
async () => {
|
||||
calls += 1;
|
||||
return { rows: [] };
|
||||
},
|
||||
{ anchor: null },
|
||||
);
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
await layer.update(),
|
||||
true,
|
||||
'a settling camera is not a failure',
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
layer.destroy();
|
||||
assert.equal(sources.length, 0);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
||||
// The proxy keeps answering with the snapshot it observed at t=1000.
|
||||
const { layer, sources, advance, now } = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
ageMs: now() - 1_000,
|
||||
stale: true,
|
||||
}));
|
||||
layer.enable();
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(29 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(2 * MIN);
|
||||
assert.equal(
|
||||
await layer.update(),
|
||||
true,
|
||||
'the replay is not an error, only no evidence',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
0,
|
||||
'the replay did not restart the window',
|
||||
);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('cells expire during prolonged fetch failures', async () => {
|
||||
let fail = false;
|
||||
const { layer, sources, advance } = harness(async () => {
|
||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
fail = true;
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1, 'still inside the window');
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||
});
|
||||
|
||||
test('cells expire while no view anchor is available', async () => {
|
||||
let calls = 0;
|
||||
const { layer, sources, advance, setAnchor } = harness(async () => {
|
||||
calls += 1;
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
setAnchor(null);
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(calls, 1, 'no anchor means no request');
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
});
|
||||
|
||||
test('re-enabling after a long disable does not show cells past the window', async () => {
|
||||
const { layer, sources, advance } = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
}));
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
layer.disable();
|
||||
advance(31 * MIN);
|
||||
layer.enable();
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
});
|
||||
|
||||
const threeCells = () => ({
|
||||
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
|
||||
});
|
||||
|
||||
test('an identical snapshot leaves the drawn entities untouched', async () => {
|
||||
const { layer, sources } = harness(async () => threeCells());
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const first = sources[0].get(0);
|
||||
await layer.update();
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
assert.equal(sources[0].get(0), first);
|
||||
});
|
||||
|
||||
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
let calls = 0;
|
||||
const h = harness(
|
||||
async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('adsb.lol HTTP 502');
|
||||
return { rows: threeHealthy() };
|
||||
},
|
||||
{ initialize: false },
|
||||
);
|
||||
const manager = new LayerLifecycle(h.viewer);
|
||||
manager.register(h.layer);
|
||||
h.layer.attachDataManager(manager);
|
||||
const changes = [];
|
||||
manager.subscribe((c) => changes.push(c.type));
|
||||
assert.equal(await manager.setEnabled(h.layer.id, true), true);
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
|
||||
assert.equal(h.layer.getStats().retryInSec, 30);
|
||||
assert.match(
|
||||
h.layer.getRowControls().info,
|
||||
/HTTP 502.*Retrying automatically at/,
|
||||
);
|
||||
assert.equal(changes.includes('visibility-failed'), false);
|
||||
assert.equal(
|
||||
await manager.refreshLayer(h.layer.id),
|
||||
false,
|
||||
'feed remains honestly unhealthy during cooldown',
|
||||
);
|
||||
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
|
||||
assert.equal(h.layer.getStats().count, 1);
|
||||
assert.equal(h.layer.getStats().retryInSec, null);
|
||||
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
|
||||
await manager.destroyAll();
|
||||
});
|
||||
|
||||
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
|
||||
retryAfterMs: 3600000,
|
||||
});
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 10, longitude: 20 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
await h.layer.update();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(h.layer.getStats().retryInSec, 3570);
|
||||
h.layer.disable();
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
|
||||
h.advance(3540000);
|
||||
t.mock.timers.tick(3540000);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
|
||||
h.layer.destroy();
|
||||
|
||||
const stopped = harness(async () => {
|
||||
throw new Error('network unavailable');
|
||||
});
|
||||
stopped.layer.enable();
|
||||
await stopped.layer.update();
|
||||
stopped.layer.disable();
|
||||
stopped.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
|
||||
stopped.layer.destroy();
|
||||
});
|
||||
|
||||
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
throw new Error('network unavailable');
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().retryInSec, 30);
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().retryInSec, 60);
|
||||
h.advance(60000);
|
||||
t.mock.timers.tick(60000);
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().retryInSec, 120);
|
||||
assert.equal(calls, 3);
|
||||
h.layer.destroy();
|
||||
const fatal = harness(async () => {
|
||||
throw Object.assign(new Error('invalid request (HTTP 400)'), {
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
fatal.layer.enable();
|
||||
assert.equal(await fatal.layer.update(), false);
|
||||
assert.equal(fatal.layer.getStats().retryInSec, null);
|
||||
fatal.layer.destroy();
|
||||
});
|
||||
|
||||
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
|
||||
const h = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
ageMs: 120000,
|
||||
stale: true,
|
||||
}));
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
return { rows: [] };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2499);
|
||||
t.mock.timers.tick(2499);
|
||||
await flush();
|
||||
assert.equal(calls, 1);
|
||||
h.advance(1);
|
||||
t.mock.timers.tick(1);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
h.advance(3000);
|
||||
h.setAnchor({ latitude: 52, longitude: 32 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(599);
|
||||
t.mock.timers.tick(599);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
h.advance(1);
|
||||
t.mock.timers.tick(1);
|
||||
await flush();
|
||||
assert.equal(calls, 3);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls = [];
|
||||
let resolve;
|
||||
const h = harness((anchor) => {
|
||||
calls.push(anchor);
|
||||
return calls.length === 1
|
||||
? new Promise((r) => (resolve = r))
|
||||
: Promise.resolve({ rows: threeHealthy() });
|
||||
});
|
||||
h.layer.enable();
|
||||
const initial = h.layer.update();
|
||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
resolve({ rows: threeHealthy() });
|
||||
assert.equal(await initial, true);
|
||||
h.advance(600);
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls.length, 2);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = harness(async () => ({ rows: [] }));
|
||||
h.layer.destroy();
|
||||
let wall = 1800000000000,
|
||||
mono = 0,
|
||||
anchor = { latitude: 50, longitude: 30 },
|
||||
calls = 0;
|
||||
const layer = createGnssIntegrityLayer({
|
||||
source: {
|
||||
getSnapshot: async () => {
|
||||
calls++;
|
||||
return { rows: [], ageMs: 0 };
|
||||
},
|
||||
},
|
||||
viewAnchor: () => anchor,
|
||||
now: () => wall,
|
||||
monotonicNow: () => mono,
|
||||
});
|
||||
layer.init(h.viewer);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
wall -= 3600000;
|
||||
mono = 3000;
|
||||
anchor = { latitude: 51, longitude: 31 };
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
mono += 600;
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
|
||||
const h = harness(async () => ({ rows: threeHealthy() }));
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
const fill = h.sources[0].get(0);
|
||||
let updates = 0;
|
||||
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
|
||||
fill.update({ passes: { pick: true } });
|
||||
assert.equal(updates, 0);
|
||||
fill.update({ passes: { render: true, pick: false } });
|
||||
assert.equal(updates, 1);
|
||||
h.layer.destroy();
|
||||
assert.equal(fill.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
|
||||
assert.equal(defaultGnssViewAnchor({}), null);
|
||||
const viewer = {
|
||||
camera: {
|
||||
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
|
||||
},
|
||||
};
|
||||
const rectangleAnchor = defaultGnssViewAnchor(viewer);
|
||||
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
|
||||
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
|
||||
viewer.camera = {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
|
||||
};
|
||||
const nadirAnchor = defaultGnssViewAnchor(viewer);
|
||||
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
|
||||
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
|
||||
*
|
||||
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
|
||||
* their own position: NIC (integrity containment radius) and NACp (position
|
||||
* accuracy). A cluster of aircraft reporting low values in one area is a
|
||||
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
|
||||
* such clusters are a well-known public proxy for GNSS interference, but
|
||||
* avionics faults and old transponders also report low values: suspected
|
||||
* interference is a second, unvalidated interpretation, never the primary
|
||||
* state.
|
||||
*
|
||||
* Three separately sourced parts make up the method (see `gnssProvenance`):
|
||||
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
|
||||
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
|
||||
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
|
||||
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
|
||||
* flag. gpsjam.org does not publish its per-aircraft threshold.
|
||||
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
|
||||
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
|
||||
*/
|
||||
|
||||
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
|
||||
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
|
||||
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
|
||||
const QUALIFYING_VERSIONS = new Set([1, 2]);
|
||||
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
|
||||
export const GNSS_NIC_THRESHOLD = 7;
|
||||
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
|
||||
export const GNSS_NACP_THRESHOLD = 8;
|
||||
/** Positions older than this are not current evidence of the local sky. */
|
||||
const MAX_POSITION_AGE_S = 60;
|
||||
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
|
||||
export const GNSS_CELL_DEG = 0.5;
|
||||
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
|
||||
export const GNSS_MIN_AIRCRAFT = 3;
|
||||
/** Rolling window over which aircraft seen in a cell are counted. */
|
||||
export const GNSS_WINDOW_MS = 30 * 60_000;
|
||||
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
|
||||
export const GNSS_MEDIUM_PERCENT = 2;
|
||||
export const GNSS_HIGH_PERCENT = 10;
|
||||
|
||||
/** Primary cell state: what the data shows (low navigation accuracy). */
|
||||
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
|
||||
export const GNSS_NOMINAL_STATE = 'nominal';
|
||||
/** Secondary, unvalidated reading of an anomaly cell. */
|
||||
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
|
||||
|
||||
/** The per-aircraft rule; GEV-defined and not validated against known events. */
|
||||
export const GNSS_CLASSIFIER = Object.freeze({
|
||||
id: 'gev-nic-nacp-v2',
|
||||
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
|
||||
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
|
||||
definedBy: 'GEV',
|
||||
validated: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Machine-readable provenance of the cell output: which part of the method
|
||||
* comes from where, and which parts are unvalidated.
|
||||
*
|
||||
* @param {{windowMs?: number}} [options] The window the layer actually uses.
|
||||
*/
|
||||
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
|
||||
return Object.freeze({
|
||||
classifier: GNSS_CLASSIFIER,
|
||||
aggregation: Object.freeze({
|
||||
id: 'gpsjam-cell-bands',
|
||||
formula: '100 * max(0, bad - 1) / total',
|
||||
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
|
||||
source: 'https://gpsjam.org/faq',
|
||||
definedBy: 'gpsjam.org',
|
||||
}),
|
||||
window: Object.freeze({
|
||||
minutes: windowMs / 60_000,
|
||||
scope: 'visited-view',
|
||||
cellDeg: GNSS_CELL_DEG,
|
||||
minAircraft: GNSS_MIN_AIRCRAFT,
|
||||
definedBy: 'GEV',
|
||||
}),
|
||||
state: Object.freeze({
|
||||
id: GNSS_ANOMALY_STATE,
|
||||
meaning: 'low navigation accuracy reported by ADS-B aircraft',
|
||||
}),
|
||||
interpretation: Object.freeze({
|
||||
id: GNSS_INTERPRETATION,
|
||||
validated: false,
|
||||
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Provenance at the default window. */
|
||||
export const GNSS_PROVENANCE = gnssProvenance();
|
||||
|
||||
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
/**
|
||||
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
|
||||
* facts the layer needs. Returns null when the payload has no aircraft array.
|
||||
*
|
||||
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
|
||||
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
|
||||
*/
|
||||
export function normalizeGnssAircraft(payload) {
|
||||
if (!Array.isArray(payload?.ac)) return null;
|
||||
const rows = [];
|
||||
const seen = new Set();
|
||||
for (const aircraft of payload.ac) {
|
||||
if (!aircraft || typeof aircraft !== 'object') continue;
|
||||
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
|
||||
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
|
||||
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
|
||||
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
|
||||
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
|
||||
let { lat, lon } = aircraft;
|
||||
const derived = (field) =>
|
||||
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
|
||||
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
|
||||
const nacp =
|
||||
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
|
||||
if (!finite(lat) || Math.abs(lat) > 90) continue;
|
||||
if (!finite(lon) || Math.abs(lon) > 180) continue;
|
||||
// Only a position confirmed within the last minute is current evidence; a
|
||||
// missing, invalid or negative age confirms nothing.
|
||||
const age = aircraft.seen_pos;
|
||||
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
|
||||
// readsb exposes the flag while GPS is lost (15 s–15 min after the
|
||||
// last good position), and removes it on recovery. Locate that evidence
|
||||
// at the last good GNSS position, rather than a later MLAT estimate.
|
||||
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
|
||||
if (nic === null && nacp === null && !gpsLost) continue;
|
||||
if (
|
||||
gpsLost &&
|
||||
finite(aircraft.gpsOkLat) &&
|
||||
Math.abs(aircraft.gpsOkLat) <= 90 &&
|
||||
finite(aircraft.gpsOkLon) &&
|
||||
Math.abs(aircraft.gpsOkLon) <= 180
|
||||
) {
|
||||
lat = aircraft.gpsOkLat;
|
||||
lon = aircraft.gpsOkLon;
|
||||
}
|
||||
seen.add(hex);
|
||||
rows.push({
|
||||
hex,
|
||||
lat,
|
||||
lon,
|
||||
nic,
|
||||
nacp,
|
||||
gpsLost,
|
||||
degraded:
|
||||
gpsLost ||
|
||||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
|
||||
(nic !== null &&
|
||||
nic < GNSS_NIC_THRESHOLD &&
|
||||
!(nic === 0 && nacp !== null && nacp >= 10)),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Stable key of the cell containing a position. */
|
||||
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
|
||||
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
|
||||
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one snapshot into a rolling observation store. Each aircraft counts
|
||||
* once per cell per window. Presence and degraded evidence age separately: a
|
||||
* degraded report keeps the aircraft degraded in that cell for one window
|
||||
* after the report itself, however many healthy reports follow, so an
|
||||
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
|
||||
* is not held degraded forever.
|
||||
*
|
||||
* Every row is dated by when the snapshot was observed, not when it arrived:
|
||||
* replaying a cached or stale snapshot never makes its evidence younger, and
|
||||
* a snapshot older than the window adds nothing.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
|
||||
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
|
||||
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
|
||||
* @returns {Map} The same store, pruned to the window.
|
||||
*/
|
||||
export function accumulateGnssObservations(
|
||||
store,
|
||||
rows,
|
||||
nowMs,
|
||||
{
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
cellDeg = GNSS_CELL_DEG,
|
||||
observedAt = nowMs,
|
||||
} = {},
|
||||
) {
|
||||
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
|
||||
if (Number.isFinite(at) && nowMs - at <= windowMs) {
|
||||
for (const row of rows || []) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
|
||||
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
|
||||
const key = `${row.hex}|${cell}`;
|
||||
const previous = store.get(key);
|
||||
const previousDegradedAt = previous?.degradedAt ?? null;
|
||||
store.set(key, {
|
||||
cell,
|
||||
seenAt: Math.max(previous?.seenAt ?? at, at),
|
||||
degradedAt: row.degraded
|
||||
? Math.max(previousDegradedAt ?? at, at)
|
||||
: previousDegradedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pruneGnssObservations(store, nowMs, { windowMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop observations older than the window and degraded evidence older than
|
||||
* the window, without adding anything. Used when no fresh snapshot arrives.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number}} [options]
|
||||
* @returns {Map} The same store.
|
||||
*/
|
||||
export function pruneGnssObservations(
|
||||
store,
|
||||
nowMs,
|
||||
{ windowMs = GNSS_WINDOW_MS } = {},
|
||||
) {
|
||||
for (const [key, entry] of store) {
|
||||
if (nowMs - entry.seenAt > windowMs) store.delete(key);
|
||||
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
|
||||
store.set(key, { ...entry, degradedAt: null });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-integrity level of a cell, using gpsjam.org's published cell
|
||||
* formula and bands: the first degraded aircraft is discounted to limit false
|
||||
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
|
||||
* high cells are navigation-integrity anomalies.
|
||||
*/
|
||||
export function gnssIntegrityLevel(total, degraded) {
|
||||
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
|
||||
const level =
|
||||
percent > GNSS_HIGH_PERCENT
|
||||
? 'high'
|
||||
: percent >= GNSS_MEDIUM_PERCENT
|
||||
? 'medium'
|
||||
: 'low';
|
||||
const anomaly = level !== 'low';
|
||||
return {
|
||||
percent,
|
||||
level,
|
||||
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
|
||||
interpretation: anomaly ? GNSS_INTERPRETATION : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the observation store into displayable cells. Expects a store
|
||||
* already pruned to the window by `accumulateGnssObservations` or
|
||||
* `pruneGnssObservations`.
|
||||
*
|
||||
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
|
||||
*/
|
||||
export function binGnssCells(
|
||||
store,
|
||||
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
|
||||
) {
|
||||
const tallies = new Map();
|
||||
for (const { cell, degradedAt } of store.values()) {
|
||||
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
|
||||
tally.aircraft += 1;
|
||||
if (degradedAt != null) tally.degraded += 1;
|
||||
tallies.set(cell, tally);
|
||||
}
|
||||
const cells = [];
|
||||
for (const [id, { aircraft, degraded }] of tallies) {
|
||||
if (aircraft < minAircraft) continue;
|
||||
const [row, col] = id.split(':').map(Number);
|
||||
const south = row * cellDeg - 90;
|
||||
const west = col * cellDeg - 180;
|
||||
const { percent, level, state, interpretation } = gnssIntegrityLevel(
|
||||
aircraft,
|
||||
degraded,
|
||||
);
|
||||
cells.push({
|
||||
id,
|
||||
south,
|
||||
west,
|
||||
north: south + cellDeg,
|
||||
east: west + cellDeg,
|
||||
lat: south + cellDeg / 2,
|
||||
lon: west + cellDeg / 2,
|
||||
aircraft,
|
||||
degraded,
|
||||
percentDegraded: Math.round(percent * 10) / 10,
|
||||
level,
|
||||
state,
|
||||
interpretation,
|
||||
});
|
||||
}
|
||||
return cells.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Legend colors: gpsjam's green / amber / red convention. */
|
||||
export const GNSS_LEVEL_COLORS = Object.freeze({
|
||||
low: '#3fb950',
|
||||
medium: '#e3b341',
|
||||
high: '#f85149',
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
GNSS_CLASSIFIER,
|
||||
GNSS_PROVENANCE,
|
||||
GNSS_WINDOW_MS,
|
||||
gnssCellKey,
|
||||
gnssIntegrityLevel,
|
||||
gnssProvenance,
|
||||
normalizeGnssAircraft,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
|
||||
const aircraft = (overrides = {}) => ({
|
||||
hex: '4ca7b5',
|
||||
type: 'adsb_icao',
|
||||
version: 2,
|
||||
alt_baro: 36000,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nac_p: 10,
|
||||
seen_pos: 1.2,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft(),
|
||||
aircraft({
|
||||
hex: '000001',
|
||||
type: 'mlat',
|
||||
nic: undefined,
|
||||
nac_p: undefined,
|
||||
}),
|
||||
aircraft({ hex: '000002', type: 'tisb_icao' }),
|
||||
aircraft({ hex: '000003', version: 0 }),
|
||||
aircraft({ hex: '000004', alt_baro: 'ground' }),
|
||||
aircraft({ hex: '000005', seen_pos: 120 }),
|
||||
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
|
||||
aircraft({ hex: '000007', lat: 95 }),
|
||||
aircraft({ hex: '000008', version: undefined }),
|
||||
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
|
||||
aircraft({ hex: '000009', version: 3 }),
|
||||
aircraft({ hex: '00000a', version: '2' }),
|
||||
aircraft({ hex: '00000b', version: 1.5 }),
|
||||
// A position is current only when its age is known, finite and 0..60 s.
|
||||
aircraft({ hex: '00000c', seen_pos: undefined }),
|
||||
aircraft({ hex: '00000d', seen_pos: null }),
|
||||
aircraft({ hex: '00000e', seen_pos: -0.5 }),
|
||||
aircraft({ hex: '00000f', seen_pos: '1.2' }),
|
||||
aircraft({ hex: '000010', seen_pos: Number.NaN }),
|
||||
aircraft({ hex: '000011', seen_pos: 60.1 }),
|
||||
aircraft({ hex: 'not-hex' }),
|
||||
aircraft(),
|
||||
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
|
||||
aircraft({ hex: '~a1b2c3' }),
|
||||
// Version 1, and both ends of the position-age range, qualify.
|
||||
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
|
||||
aircraft({ hex: 'b00002', seen_pos: 60 }),
|
||||
null,
|
||||
],
|
||||
});
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
hex: '4ca7b5',
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
},
|
||||
{
|
||||
hex: '~a1b2c3',
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
},
|
||||
...['b00001', 'b00002'].map((hex) => ({
|
||||
hex,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
})),
|
||||
]);
|
||||
assert.equal(normalizeGnssAircraft({}), null);
|
||||
assert.equal(normalizeGnssAircraft(null), null);
|
||||
});
|
||||
|
||||
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({ hex: 'a00001', nic: 6 }),
|
||||
aircraft({ hex: 'a00002', nac_p: 7 }),
|
||||
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
|
||||
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
|
||||
[
|
||||
['a00001', true, false],
|
||||
['a00002', true, false],
|
||||
['a00003', true, true],
|
||||
['a00004', false, false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
|
||||
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
|
||||
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
|
||||
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
|
||||
assert.equal(gnssCellKey(-90, -180), '0:0');
|
||||
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
|
||||
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
|
||||
});
|
||||
|
||||
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
|
||||
const nominal = {
|
||||
percent: 0,
|
||||
level: 'low',
|
||||
state: 'nominal',
|
||||
interpretation: null,
|
||||
};
|
||||
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
|
||||
// One degraded aircraft is always discounted.
|
||||
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
|
||||
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
|
||||
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
|
||||
});
|
||||
|
||||
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
|
||||
for (const [total, bad] of [
|
||||
[50, 2],
|
||||
[10, 3],
|
||||
]) {
|
||||
const { state, interpretation } = gnssIntegrityLevel(total, bad);
|
||||
assert.equal(state, 'navigation-integrity-anomaly');
|
||||
assert.equal(interpretation, 'suspected-gnss-interference');
|
||||
}
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
|
||||
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
|
||||
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const [cell] = binGnssCells(store);
|
||||
assert.equal(cell.level, 'high');
|
||||
assert.equal(cell.state, 'navigation-integrity-anomaly');
|
||||
assert.equal(cell.interpretation, 'suspected-gnss-interference');
|
||||
for (const key of Object.keys(cell))
|
||||
assert.doesNotMatch(key, /interference|jamming|spoof/i);
|
||||
});
|
||||
|
||||
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
|
||||
assert.deepEqual(GNSS_PROVENANCE.classifier, {
|
||||
id: 'gev-nic-nacp-v2',
|
||||
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
|
||||
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
|
||||
definedBy: 'GEV',
|
||||
validated: false,
|
||||
});
|
||||
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
|
||||
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
|
||||
id: 'gpsjam-cell-bands',
|
||||
formula: '100 * max(0, bad - 1) / total',
|
||||
bands: [2, 10],
|
||||
source: 'https://gpsjam.org/faq',
|
||||
definedBy: 'gpsjam.org',
|
||||
});
|
||||
assert.deepEqual(GNSS_PROVENANCE.window, {
|
||||
minutes: 30,
|
||||
scope: 'visited-view',
|
||||
cellDeg: 0.5,
|
||||
minAircraft: 3,
|
||||
definedBy: 'GEV',
|
||||
});
|
||||
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
|
||||
assert.equal(
|
||||
GNSS_PROVENANCE.interpretation.id,
|
||||
'suspected-gnss-interference',
|
||||
);
|
||||
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
|
||||
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
|
||||
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
|
||||
// The bands in provenance are the ones the level function applies.
|
||||
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
|
||||
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
|
||||
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
|
||||
});
|
||||
|
||||
test('observations count each aircraft once per cell and expire after the window', () => {
|
||||
const store = new Map();
|
||||
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
|
||||
0,
|
||||
{ windowMs: 1000 },
|
||||
);
|
||||
// a1 recovers inside the same cell: it stays degraded for the window.
|
||||
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
|
||||
windowMs: 1000,
|
||||
});
|
||||
let [cell] = binGnssCells(store);
|
||||
assert.equal(cell.aircraft, 3);
|
||||
assert.equal(cell.degraded, 1);
|
||||
assert.equal(cell.level, 'low');
|
||||
assert.deepEqual(
|
||||
[cell.south, cell.west, cell.north, cell.east],
|
||||
[50, 20, 50.5, 20.5],
|
||||
);
|
||||
|
||||
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
|
||||
assert.equal(store.size, 1);
|
||||
assert.deepEqual(binGnssCells(store), []);
|
||||
});
|
||||
|
||||
const MIN = 60_000;
|
||||
const report = (hex, degraded, lat = 50.1) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon: 20.1,
|
||||
degraded,
|
||||
});
|
||||
const entry = (store, hex) =>
|
||||
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
|
||||
|
||||
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 keeps reporting healthy every minute in the same cell.
|
||||
for (let minute = 1; minute <= 30; minute += 1)
|
||||
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
|
||||
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
|
||||
assert.equal(
|
||||
entry(store, 'a1').degradedAt,
|
||||
0,
|
||||
'healthy reports do not refresh the degraded evidence',
|
||||
);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 31 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// A new degraded report starts its own window.
|
||||
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
});
|
||||
|
||||
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
|
||||
const store = new Map();
|
||||
const healthy = [report('a2', false), report('a3', false)];
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', true), report('b1', true), ...healthy],
|
||||
0,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].level, 'high');
|
||||
for (let minute = 10; minute <= 30; minute += 10)
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
minute * MIN,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].degraded, 2);
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
31 * MIN,
|
||||
);
|
||||
const [cell] = binGnssCells(store);
|
||||
assert.equal(cell.aircraft, 4);
|
||||
assert.equal(cell.degraded, 0);
|
||||
assert.equal(cell.level, 'low');
|
||||
});
|
||||
|
||||
test('an aircraft returning after the window starts fresh', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 leaves; nothing is heard from it for longer than the window.
|
||||
accumulateGnssObservations(store, [], 31 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 32 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// Returning degraded dates the evidence from the new report.
|
||||
const again = new Map();
|
||||
accumulateGnssObservations(again, [report('a9', true)], 0);
|
||||
accumulateGnssObservations(again, [], 31 * MIN);
|
||||
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
|
||||
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
|
||||
});
|
||||
|
||||
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
|
||||
// No prune runs in between: the old entry is still in the store when the
|
||||
// healthy report arrives, so the new report alone must not carry it over.
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 31 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// Exactly one window after the degraded report it still counts.
|
||||
const edge = new Map();
|
||||
accumulateGnssObservations(edge, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
|
||||
assert.equal(entry(edge, 'a1').degradedAt, 0);
|
||||
});
|
||||
|
||||
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
|
||||
const store = new Map();
|
||||
const snapshot = [report('a1', true), report('a2', false)];
|
||||
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
|
||||
// The same snapshot replayed (cached or stale) 29 minutes later.
|
||||
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
|
||||
assert.equal(entry(store, 'a1').seenAt, 0);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 0);
|
||||
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
|
||||
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
|
||||
|
||||
// An older replay never moves an entry's age backwards either.
|
||||
accumulateGnssObservations(store, snapshot, 40 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
|
||||
observedAt: 35 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
|
||||
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
|
||||
|
||||
// A future time is clamped to now; an undated snapshot adds nothing.
|
||||
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
|
||||
observedAt: 99 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
|
||||
for (const observedAt of [null, Number.NaN])
|
||||
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
|
||||
observedAt,
|
||||
});
|
||||
assert.equal(entry(store, 'u1'), undefined);
|
||||
});
|
||||
|
||||
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', false)], 0);
|
||||
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
|
||||
assert.deepEqual(
|
||||
[...store.keys()].map((key) => key.split('|')[0]),
|
||||
['a1'],
|
||||
);
|
||||
assert.equal(entry(store, 'a1').degradedAt, null);
|
||||
pruneGnssObservations(store, 51 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
|
||||
});
|
||||
|
||||
test('rows without finite coordinates never key a cell', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
|
||||
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
|
||||
null,
|
||||
],
|
||||
0,
|
||||
);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('cells below the minimum aircraft count are withheld', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
|
||||
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
|
||||
],
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(binGnssCells(store), []);
|
||||
const [cell] = binGnssCells(store, { minAircraft: 2 });
|
||||
assert.equal(cell.level, 'high');
|
||||
assert.equal(cell.percentDegraded, 50);
|
||||
});
|
||||
|
||||
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
|
||||
const [row] = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({
|
||||
type: 'mlat',
|
||||
nac_p: 0,
|
||||
nic: 0,
|
||||
mlat: ['lat', 'lon', 'nic'],
|
||||
gpsOkBefore: 1800000000,
|
||||
gpsOkLat: 54.2,
|
||||
gpsOkLon: 19.3,
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.ok(row);
|
||||
assert.equal(row.lat, 54.2);
|
||||
assert.equal(row.lon, 19.3);
|
||||
assert.equal(row.nic, null);
|
||||
assert.equal(row.degraded, true);
|
||||
const [healthy] = normalizeGnssAircraft({
|
||||
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
|
||||
});
|
||||
assert.equal(healthy.degraded, false);
|
||||
});
|
||||
|
||||
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
|
||||
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
|
||||
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
|
||||
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
|
||||
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.degraded),
|
||||
[false, false, false, true, true],
|
||||
);
|
||||
});
|
||||
|
||||
// Quality-field subsets captured from public adsb.lol US point responses on
|
||||
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
|
||||
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
|
||||
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
|
||||
const tisbQuality = {
|
||||
nic: 0,
|
||||
nac_p: 0,
|
||||
tisb: [
|
||||
'altitude',
|
||||
'alt_baro',
|
||||
'gs',
|
||||
'track',
|
||||
'geom_rate',
|
||||
'lat',
|
||||
'lon',
|
||||
'nic',
|
||||
'rc',
|
||||
'nac_p',
|
||||
'nac_v',
|
||||
'sil',
|
||||
'sil_type',
|
||||
],
|
||||
mlat: [],
|
||||
};
|
||||
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
|
||||
const unknownContainment = {
|
||||
nic: 0,
|
||||
nac_p: 10,
|
||||
tisb: ['callsign'],
|
||||
mlat: [],
|
||||
};
|
||||
assert.equal(
|
||||
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
function retryDelay(raw, nowMs) {
|
||||
if (!raw) return null;
|
||||
const seconds = Number(raw);
|
||||
const ms = seconds * 1000;
|
||||
if (Number.isFinite(ms) && seconds > 0)
|
||||
return Math.min(3600000, Math.max(5000, ms));
|
||||
const at = Date.parse(raw);
|
||||
return Number.isFinite(at) && at > nowMs
|
||||
? Math.min(3600000, Math.max(5000, at - nowMs))
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
|
||||
export function createAdsbGnssSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||
throw new Error('GNSS snapshot needs a view anchor');
|
||||
const params = new URLSearchParams({
|
||||
lat: String(Math.round(latitude)),
|
||||
lon: String(Math.round(longitude)),
|
||||
});
|
||||
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const limited = response.status === 429;
|
||||
const retryAfterMs = retryDelay(
|
||||
response.headers.get('retry-after'),
|
||||
now(),
|
||||
);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
limited
|
||||
? 'GNSS feed rate limited (HTTP 429)'
|
||||
: `GNSS feed unavailable (HTTP ${response.status})`,
|
||||
),
|
||||
{
|
||||
status: response.status,
|
||||
retryable: limited || response.status >= 500,
|
||||
retryAfterMs: retryAfterMs ?? (limited ? 60000 : null),
|
||||
},
|
||||
);
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
4 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed GNSS integrity snapshot');
|
||||
// The snapshot's age (measured on the proxy's clock) dates every row in
|
||||
// the rolling window; an undated snapshot could only be treated as new,
|
||||
// so it is refused.
|
||||
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
|
||||
throw new Error('GNSS integrity snapshot has no observation time');
|
||||
return {
|
||||
rows: payload.rows,
|
||||
ageMs: payload.ageMs,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
: null,
|
||||
stale: payload.stale === true,
|
||||
classifier:
|
||||
typeof payload.classifier === 'string' ? payload.classifier : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAdsbGnssSource } from './source.js';
|
||||
|
||||
test('the source asks the same-origin proxy for the view anchor', async () => {
|
||||
const calls = [];
|
||||
const source = createAdsbGnssSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(url);
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
return Response.json({
|
||||
fetchedAt: 5,
|
||||
ageMs: 0,
|
||||
classifier: 'gev-nic-nacp-v2',
|
||||
rows: [{ hex: 'abc123' }],
|
||||
});
|
||||
},
|
||||
});
|
||||
const snapshot = await source.getSnapshot(
|
||||
{ latitude: 50.06143, longitude: 19.93658 },
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
|
||||
assert.deepEqual(snapshot, {
|
||||
rows: [{ hex: 'abc123' }],
|
||||
ageMs: 0,
|
||||
fetchedAt: 5,
|
||||
stale: false,
|
||||
classifier: 'gev-nic-nacp-v2',
|
||||
});
|
||||
});
|
||||
|
||||
test('the source reports stale proxy data and rejects bad input or responses', async () => {
|
||||
const reply = (body, init) => async () => Response.json(body, init);
|
||||
const stale = await createAdsbGnssSource({
|
||||
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
|
||||
assert.equal(stale.fetchedAt, 7);
|
||||
assert.equal(stale.classifier, null);
|
||||
// Without an observation age the rows could only be dated as new.
|
||||
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({
|
||||
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||
/no observation time/,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
|
||||
/view anchor/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({
|
||||
fetchImpl: reply({ error: 'x' }, { status: 502 }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||
/HTTP 502/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
}),
|
||||
/Malformed/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
|
||||
{ latitude: 1, longitude: 2 },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
|
||||
const at = Date.UTC(2026, 9, 13, 12);
|
||||
for (const [status, header, delay, retryable] of [
|
||||
[429, '3600', 3600000, true],
|
||||
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
|
||||
[429, null, 60000, true],
|
||||
[429, 'Infinity', 60000, true],
|
||||
[502, '5', 5000, true],
|
||||
[502, null, null, true],
|
||||
[400, null, null, false],
|
||||
]) {
|
||||
const source = createAdsbGnssSource({
|
||||
now: () => at,
|
||||
fetchImpl: async () =>
|
||||
Response.json(
|
||||
{},
|
||||
{ status, headers: header ? { 'Retry-After': header } : {} },
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ latitude: 50, longitude: 20 }),
|
||||
(e) => {
|
||||
assert.equal(e.status, status);
|
||||
assert.equal(e.retryAfterMs, delay);
|
||||
assert.equal(e.retryable, retryable);
|
||||
assert.match(
|
||||
e.message,
|
||||
status === 429 ? /rate limited/ : /feed unavailable/,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
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 =
|
||||
|
||||
@@ -4,22 +4,47 @@ import { readFileSync } from 'node:fs';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
|
||||
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
|
||||
import {
|
||||
createMilitaryNamesLoader,
|
||||
loadMilitaryNames,
|
||||
} from '../../data/militaryNames.js';
|
||||
import { loadBundledJson } from '../../data/bundledJson.js';
|
||||
|
||||
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
|
||||
const wide = { south: 20, west: -130, north: 60, east: -60 };
|
||||
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
|
||||
function sourceWith(job) { return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
}); }
|
||||
const tile = decodeOpenFreeMapMilitaryTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
12,
|
||||
935,
|
||||
1685,
|
||||
);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function sourceWith(job) {
|
||||
return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
});
|
||||
}
|
||||
|
||||
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
|
||||
const job = deferred(), source = sourceWith(job), abort = new AbortController();
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
abort = new AbortController();
|
||||
const waiting = source.getMappedSites(wide, { signal: abort.signal });
|
||||
const rejection = assert.rejects(waiting, { name: 'AbortError' });
|
||||
const payload = await source.getMappedSites(box);
|
||||
@@ -32,48 +57,81 @@ test('close polygons publish immediately while a wide consumer can cancel its in
|
||||
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
|
||||
});
|
||||
|
||||
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(), source = sourceWith(job), publications = [];
|
||||
let currentBox = box;
|
||||
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
|
||||
const ingestion = createIngestion({ state, source,
|
||||
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
|
||||
parts: {
|
||||
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
|
||||
},
|
||||
for (const stale of [false, true])
|
||||
test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
publications = [];
|
||||
let currentBox = box;
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {},
|
||||
records: [],
|
||||
googleSearchRequested: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
parts: {
|
||||
viewport: {
|
||||
loadArea: () => ({ box: currentBox, coverage: {} }),
|
||||
clearUnavailableRetry() {},
|
||||
scheduleUnavailableRetry() {},
|
||||
},
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: {
|
||||
renderRecords: () => publications.push(state.records),
|
||||
warmInstallationFloors() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) {
|
||||
currentBox = wide;
|
||||
state.enabled = false;
|
||||
state.abort.abort();
|
||||
}
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
|
||||
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0, signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++; signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
for (const stage of ['response', 'body'])
|
||||
test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0,
|
||||
signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++;
|
||||
signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
});
|
||||
const load = createMilitaryNamesLoader({
|
||||
timeoutMs: 50,
|
||||
cooldownMs: 100,
|
||||
loadPack: (signal) =>
|
||||
loadBundledJson(new URL('https://example.test/names.json'), { signal }),
|
||||
});
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush();
|
||||
t.mock.timers.tick(51);
|
||||
await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
|
||||
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush(); t.mock.timers.tick(51); await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ export function createInstallationSource({
|
||||
} = {}) {
|
||||
let overpassUnavailable = false;
|
||||
const installationIds = new Map();
|
||||
async function getNamedSites(box, signal) {
|
||||
async function getNamedSites(box, signal, thinned = true) {
|
||||
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
|
||||
signal?.throwIfAborted();
|
||||
throw Object.assign(
|
||||
@@ -112,7 +112,13 @@ export function createInstallationSource({
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const { records, count } = militaryNamesInView(names, box);
|
||||
// The map keeps one site per display cell; queries can ask for all.
|
||||
const { records, count } = militaryNamesInView(
|
||||
names,
|
||||
box,
|
||||
thinned ? undefined : Infinity,
|
||||
thinned,
|
||||
);
|
||||
return {
|
||||
records,
|
||||
namedInView: count,
|
||||
@@ -124,9 +130,9 @@ export function createInstallationSource({
|
||||
source: 'OpenStreetMap',
|
||||
};
|
||||
}
|
||||
async function getTileSites(box, signal) {
|
||||
async function getTileSites(box, signal, thinned) {
|
||||
const zoom = installationTileZoom(box);
|
||||
if (zoom === null) return getNamedSites(box, signal);
|
||||
if (zoom === null) return getNamedSites(box, signal, thinned);
|
||||
let names = null;
|
||||
const namesJob = waitForSignal(loadNames(), signal)
|
||||
.then((loaded) => {
|
||||
@@ -181,7 +187,7 @@ export function createInstallationSource({
|
||||
mapTiles.clear();
|
||||
installationIds.clear();
|
||||
},
|
||||
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
|
||||
const { south, west, north, east } = box || {};
|
||||
if (
|
||||
![south, west, north, east].every(Number.isFinite) ||
|
||||
@@ -197,8 +203,8 @@ export function createInstallationSource({
|
||||
throw new TypeError('A bounded installation viewport is required');
|
||||
signal?.throwIfAborted();
|
||||
if (east < west || north - south > 10 || east - west > 10)
|
||||
return getNamedSites(box, signal);
|
||||
if (overpassUnavailable) return getTileSites(box, signal);
|
||||
return getNamedSites(box, signal, thinned);
|
||||
if (overpassUnavailable) return getTileSites(box, signal, thinned);
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
@@ -213,7 +219,7 @@ export function createInstallationSource({
|
||||
signal?.throwIfAborted();
|
||||
if (isUnavailableCapability(body)) {
|
||||
overpassUnavailable = true;
|
||||
return getTileSites(box, signal);
|
||||
return getTileSites(box, signal, thinned);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Object.assign(
|
||||
|
||||
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,
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
export function createLaunchSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/** The launch payload, and whether the proxy served it from a stale cache. */
|
||||
async function getLaunchSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/launches', { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||
throw new Error('Malformed launch snapshot');
|
||||
return {
|
||||
payload,
|
||||
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
async getLaunches({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/launches', { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||
throw new Error('Malformed launch snapshot');
|
||||
return payload;
|
||||
getLaunchSnapshot,
|
||||
async getLaunches(options) {
|
||||
return (await getLaunchSnapshot(options)).payload;
|
||||
},
|
||||
async getActiveTle({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
/**
|
||||
* @module militaryFlights
|
||||
@@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
||||
|
||||
/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */
|
||||
|
||||
export const EMERGENCY_ICON_COLOR =
|
||||
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
|
||||
|
||||
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
|
||||
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
|
||||
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CONTACT_MATCH_TIER,
|
||||
} from '../../data/contactMatch.js';
|
||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
flightState,
|
||||
@@ -133,6 +134,10 @@ export function createQueries({
|
||||
flightState.records.missingPolls.get(icao24) ||
|
||||
flightState.feed._backoff,
|
||||
),
|
||||
// This poll's transponder broadcast (not sticky; see records.js).
|
||||
squawk: info?.squawk ?? null,
|
||||
emergency: info?.emergency ? { ...info.emergency } : null,
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +161,9 @@ export function createQueries({
|
||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||
* military: boolean, aircraftClass: string|null, originCountry: null,
|
||||
* operator: string|null, routeOrigin: null, routeDestination: null}}
|
||||
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
|
||||
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(icao24, info) {
|
||||
@@ -187,6 +194,20 @@ export function createQueries({
|
||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||
originCountry: null,
|
||||
operator: text(info?.operator),
|
||||
squawk: text(info?.squawk),
|
||||
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||
// without the flag.
|
||||
emergency: info?.emergency?.severity === 'emergency',
|
||||
emergencyKind: text(info?.emergency?.kind),
|
||||
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||
emergencySource: text(info?.emergency?.source),
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
// Latest known general aircraft-message contact, retained if a poll
|
||||
// has no time. This is not the receipt time of the current status.
|
||||
lastContactAtMs: num(info?.lastContactEpochMs),
|
||||
routeOrigin: null,
|
||||
routeDestination: null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||||
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
@@ -161,6 +162,9 @@ export class MilitaryFlightRecords {
|
||||
null,
|
||||
),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
// Per poll, NOT sticky (mirror of flights): a cleared code clears here.
|
||||
squawk: aircraft.squawk ?? null,
|
||||
emergency: aircraftEmergency(aircraft),
|
||||
onGround,
|
||||
// Round 7: sticky airborne history (see _likelyLanded).
|
||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||
|
||||
@@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only
|
||||
'remove',
|
||||
);
|
||||
});
|
||||
|
||||
test('military emergency status follows the latest poll instead of sticking', () => {
|
||||
const records = store();
|
||||
const first = records.receive(
|
||||
observation({ squawk: '1234', emergency: 'minfuel' }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(first.meta.squawk, '1234');
|
||||
assert.equal(first.meta.emergency.kind, 'minfuel');
|
||||
assert.equal(first.meta.emergency.severity, 'priority');
|
||||
const next = records.receive(
|
||||
observation({ squawk: '1234', emergency: 'none' }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(next.meta.emergency, null);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
PLANE_MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
MIL_ICON_COLOR,
|
||||
TRACKED_ICON_COLOR,
|
||||
EMERGENCY_ICON_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
MODEL_MAX_ALL,
|
||||
MODEL_MAX,
|
||||
@@ -159,7 +161,7 @@ export function createRendering({
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.scale = limbScale;
|
||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
|
||||
bb.rotation = 0;
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +175,9 @@ export function createRendering({
|
||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||
bb.scale = _militaryBillboardScale(icao24) * limbScale;
|
||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
|
||||
cyberSonarBaseAlpha(bb),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||
@@ -189,7 +193,17 @@ export function createRendering({
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
||||
return MIL_ICON_COLOR;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
||||
* amber otherwise. Cockpit far-contact dots use it too. */
|
||||
|
||||
function _fleetBillboardColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -965,7 +979,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor = MIL_ICON_COLOR;
|
||||
const baseColor = _fleetBillboardColor(icao24, info);
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
@@ -1082,6 +1096,7 @@ export function createRendering({
|
||||
_applyFleetBillboardPresentation,
|
||||
_iconKind,
|
||||
_modelColor,
|
||||
_fleetBillboardColor,
|
||||
_modelRegimeActive,
|
||||
_modelCap,
|
||||
_modelAddDistM,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
@@ -7,12 +8,7 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import {
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
TRACKED_ICON_COLOR,
|
||||
MIL_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -23,6 +19,7 @@ export function createMilitarySnapshotRenderer({
|
||||
militaryRegistry,
|
||||
rendering,
|
||||
tracking,
|
||||
motion,
|
||||
queries,
|
||||
}) {
|
||||
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
||||
@@ -124,6 +121,11 @@ export function createMilitarySnapshotRenderer({
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (!isTracked) {
|
||||
bb.color = rendering
|
||||
._fleetBillboardColor(icao24, meta)
|
||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
// Ground flips (landing/takeoff) restyle this SAME billboard in
|
||||
@@ -142,8 +144,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,
|
||||
@@ -154,7 +162,9 @@ export function createMilitarySnapshotRenderer({
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR,
|
||||
color: isTracked
|
||||
? TRACKED_ICON_COLOR
|
||||
: rendering._fleetBillboardColor(icao24, meta),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
MIL_ICON_COLOR,
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
@@ -780,12 +779,16 @@ export function createTracking({
|
||||
bb.width = 20;
|
||||
bb.height = 20;
|
||||
// Ground-aware restore (a plane untracked while taxiing comes back at
|
||||
// ground scale; tint is full amber on the ground and in the air).
|
||||
// ground scale; the fleet tint is amber, or red while broadcasting an
|
||||
// emergency, on the ground and in the air).
|
||||
bb.scale =
|
||||
BILLBOARD_SCALE *
|
||||
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
||||
(meta?.onGround ? GROUND_SCALE : 1);
|
||||
bb.color = MIL_ICON_COLOR;
|
||||
bb.color = parts.rendering._fleetBillboardColor(
|
||||
flightState._trackedIcao,
|
||||
meta,
|
||||
);
|
||||
bb.rotation = flightState._lastTrackedRotation;
|
||||
}
|
||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||
|
||||
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
|
||||
/**
|
||||
* Read one product's granules across CMR pages, sending back each response's
|
||||
* `CMR-Search-After` cursor until a short page, a missing cursor or the
|
||||
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
|
||||
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
|
||||
* read, or the cap dropped valid granules from the last page read.
|
||||
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
|
||||
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
|
||||
*/
|
||||
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
|
||||
}) {
|
||||
const granules = [];
|
||||
let hits = 0;
|
||||
let readCount = 0;
|
||||
let capped = 0;
|
||||
let cursor = null;
|
||||
for (;;) {
|
||||
const init = { signal };
|
||||
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
|
||||
const response = await fetchImpl(url, init);
|
||||
if (!response?.ok)
|
||||
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
|
||||
const page = parseCmrUmm(await response.json(), product);
|
||||
granules.push(...page.granules);
|
||||
const json = await response.json();
|
||||
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
|
||||
const page = parseCmrUmm(json, product);
|
||||
readCount += rawCount;
|
||||
const admitted = page.granules.slice(
|
||||
0,
|
||||
Math.max(0, maxRecords - granules.length),
|
||||
);
|
||||
granules.push(...admitted);
|
||||
capped += page.granules.length - admitted.length;
|
||||
hits = Math.max(hits, page.hits);
|
||||
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
|
||||
if (
|
||||
!cursor ||
|
||||
page.granules.length < CMR_PAGE_SIZE ||
|
||||
granules.length >= maxRecords ||
|
||||
granules.length >= hits
|
||||
rawCount < CMR_PAGE_SIZE ||
|
||||
readCount >= maxRecords ||
|
||||
readCount >= hits
|
||||
)
|
||||
break;
|
||||
}
|
||||
return { granules, truncated: hits > granules.length };
|
||||
return { granules, truncated: hits > readCount || capped > 0 };
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchCmrPages } from './catalog.js';
|
||||
const item = (id) => ({
|
||||
meta: { 'concept-id': id },
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
test('discarded rows do not turn a full CMR page into a final page', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items =
|
||||
calls === 1
|
||||
? Array.from({ length: 200 }, (_, i) =>
|
||||
i === 0 ? {} : item(String(i)),
|
||||
)
|
||||
: [item('last')];
|
||||
return Response.json(
|
||||
{ hits: 201, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 200);
|
||||
assert.equal(result.granules.at(-1).id, 'last');
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 1000,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 400,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
|
||||
);
|
||||
return Response.json(
|
||||
{ hits: 400, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
boxCentre,
|
||||
boxFromRectangle,
|
||||
dequantizeBox,
|
||||
validateBox,
|
||||
} from './model.js';
|
||||
import { searchHls } from './catalog.js';
|
||||
|
||||
test('missing imagery edges do not turn into invented zero coordinates', () => {
|
||||
for (const value of [null, undefined, '', ' ', false, true]) {
|
||||
const box = { west: value, south: 0, east: 1, north: 1 };
|
||||
assert.equal(validateBox(box).ok, false);
|
||||
assert.equal(validateBox(box).reason, 'invalid');
|
||||
assert.equal(boxCentre(box), null);
|
||||
}
|
||||
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
|
||||
assert.equal(
|
||||
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('every edge and every box entry point refuses a missing edge', async () => {
|
||||
const blanks = [null, undefined, '', ' ', false, [], {}];
|
||||
for (const edge of ['west', 'south', 'east', 'north']) {
|
||||
for (const value of blanks) {
|
||||
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
|
||||
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
|
||||
// Share-link integers and Cesium radians are converted per edge.
|
||||
assert.equal(dequantizeBox(box), null, `share ${edge}`);
|
||||
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
|
||||
}
|
||||
}
|
||||
let fetched = false;
|
||||
await assert.rejects(
|
||||
searchHls({
|
||||
box: { west: ' ', south: 1, east: 2, north: 2 },
|
||||
fetchImpl: async () => {
|
||||
fetched = true;
|
||||
},
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(fetched, false);
|
||||
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
|
||||
});
|
||||
@@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
|
||||
* east are not swapped: west > east crosses the dateline and `validateBox`
|
||||
* rejects it. Null when any edge is nonfinite.
|
||||
*/
|
||||
/** A box edge as a number, or null when it is missing, blank or not numeric. */
|
||||
function edgeNumber(value) {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function normalizeBox(box) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
const [west, south, east, north] = [
|
||||
@@ -82,8 +90,8 @@ function normalizeBox(box) {
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
].map(Number);
|
||||
if (![west, south, east, north].every(Number.isFinite)) return null;
|
||||
].map(edgeNumber);
|
||||
if ([west, south, east, north].some((edge) => edge == null)) return null;
|
||||
return {
|
||||
west,
|
||||
south: Math.min(south, north),
|
||||
@@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
|
||||
|
||||
function mapBox(box, fn) {
|
||||
if (!box || typeof box !== 'object') return null;
|
||||
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
|
||||
const edge = (value) => {
|
||||
const number = edgeNumber(value);
|
||||
return number == null ? null : fn(number);
|
||||
};
|
||||
return normalizeBox({
|
||||
west: fn(box.west),
|
||||
south: fn(box.south),
|
||||
east: fn(box.east),
|
||||
north: fn(box.north),
|
||||
west: edge(box.west),
|
||||
south: edge(box.south),
|
||||
east: edge(box.east),
|
||||
north: edge(box.north),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,36 @@
|
||||
import { searchHls } from './catalog.js';
|
||||
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
|
||||
|
||||
/**
|
||||
* NASA-compatible imagery acquisition; transport and URL builders are replaceable.
|
||||
* Thumbnail responses must expose Data-Present: true for image admission, or
|
||||
* false for confirmed empty coverage, including injected getThumbnail sources.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createThumbnailLoader } from './thumbnails.js';
|
||||
test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => {
|
||||
for (const header of [null, 'unknown', '', 'true', 'false']) {
|
||||
let decodes = 0;
|
||||
const loader = createThumbnailLoader({
|
||||
requestThumbnail: async () => ({
|
||||
ok: true,
|
||||
headers: { get: (key) => (key === 'Data-Present' ? header : null) },
|
||||
blob: async () => {
|
||||
decodes++;
|
||||
return new Blob(['fixture']);
|
||||
},
|
||||
}),
|
||||
createObjectUrl: () => 'blob:fixture',
|
||||
});
|
||||
loader.request(
|
||||
{ key: 'fixture', product: 'S30', day: '2026-09-18' },
|
||||
{ west: 0, east: 1, south: 0, north: 1 },
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
loader.get('fixture').status,
|
||||
header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error',
|
||||
);
|
||||
assert.equal(decodes, header === 'true' ? 1 : 0);
|
||||
loader.destroy();
|
||||
}
|
||||
});
|
||||
@@ -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,24 +92,20 @@ 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) {
|
||||
const present = String(
|
||||
response.headers?.get?.('Data-Present') ?? 'true',
|
||||
response.headers?.get?.('Data-Present') ?? '',
|
||||
).toLowerCase();
|
||||
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
|
||||
if (present === 'false') {
|
||||
status = 'empty';
|
||||
} else {
|
||||
} else if (present === 'true') {
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) cancelled = true;
|
||||
else {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -40,24 +41,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Parse TLE text into array of { name, line1, line2 } objects.
|
||||
*/
|
||||
|
||||
function parseTLE(text) {
|
||||
const lines = text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const result = [];
|
||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
result.push({ name, line1, line2 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const parseTLE = parseTleText;
|
||||
|
||||
/**
|
||||
* Propagate satellite position at a given JS Date.
|
||||
|
||||
@@ -19,7 +19,10 @@ export function createSatelliteSource({
|
||||
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
|
||||
const text = response.ok ? await response.text() : '';
|
||||
signal?.throwIfAborted();
|
||||
return { ok: response.ok, status: response.status, text };
|
||||
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
|
||||
// is down.
|
||||
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
|
||||
return { ok: response.ok, status: response.status, text, stale };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,64 @@
|
||||
/**
|
||||
* An image's pixel size read from its header, before anything decodes it:
|
||||
* a picture host can serve a small file that declares enormous dimensions,
|
||||
* and a decode of it alone can take the tab (or the GPU process) down.
|
||||
* JPEG (SOFn), PNG (IHDR), WebP (VP8, VP8L, VP8X) and GIF; null for any
|
||||
* other format, or a header this does not reach.
|
||||
* @param {Uint8Array} bytes The start of the file (a JPEG's EXIF can push its SOF past 64 kB).
|
||||
* @returns {{width: number, height: number}|null}
|
||||
*/
|
||||
export function imageSize(bytes) {
|
||||
if (!bytes || bytes.length < 12) return null;
|
||||
const b = bytes;
|
||||
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
||||
const le16 = (i) => b[i] | (b[i + 1] << 8);
|
||||
const le24 = (i) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
|
||||
const ascii = (i, text) =>
|
||||
[...text].every((char, k) => b[i + k] === char.charCodeAt(0));
|
||||
// JPEG: walk the markers to the first start-of-frame.
|
||||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||
let i = 2;
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) return null;
|
||||
const marker = b[i + 1];
|
||||
// Fill bytes, and markers with no length.
|
||||
if (marker === 0xff) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const isFrame =
|
||||
marker >= 0xc0 &&
|
||||
marker <= 0xcf &&
|
||||
marker !== 0xc4 &&
|
||||
marker !== 0xc8 &&
|
||||
marker !== 0xcc;
|
||||
if (isFrame) return { width: be16(i + 7), height: be16(i + 5) };
|
||||
i += 2 + be16(i + 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// PNG: IHDR is the first chunk.
|
||||
if (b[0] === 0x89 && ascii(1, 'PNG') && ascii(12, 'IHDR')) {
|
||||
const u32 = (i) =>
|
||||
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
||||
return { width: u32(16), height: u32(20) };
|
||||
}
|
||||
if (ascii(0, 'RIFF') && ascii(8, 'WEBP') && b.length >= 30) {
|
||||
if (ascii(12, 'VP8 '))
|
||||
return { width: le16(26) & 0x3fff, height: le16(28) & 0x3fff };
|
||||
if (ascii(12, 'VP8L'))
|
||||
return {
|
||||
width: 1 + (((b[22] & 0x3f) << 8) | b[21]),
|
||||
height:
|
||||
1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)),
|
||||
};
|
||||
if (ascii(12, 'VP8X')) return { width: 1 + le24(24), height: 1 + le24(27) };
|
||||
return null;
|
||||
}
|
||||
if (ascii(0, 'GIF8')) return { width: le16(6), height: le16(8) };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { imageSize } from './imageSize.js';
|
||||
|
||||
const bytes = (...parts) =>
|
||||
Uint8Array.from(
|
||||
parts.flat().map((v) => (typeof v === 'string' ? v.charCodeAt(0) : v)),
|
||||
);
|
||||
const ascii = (text) => [...text];
|
||||
|
||||
test('a JPEG’s size is read from its first frame, past an EXIF segment', () => {
|
||||
const exif = [0xff, 0xe1, 0x00, 0x10, ...new Array(14).fill(0)];
|
||||
const sof = [0xff, 0xc0, 0x00, 0x11, 0x08, 0x0b, 0xc0, 0x0f, 0xc0];
|
||||
const jpeg = bytes([0xff, 0xd8], exif, sof, new Array(20).fill(0));
|
||||
assert.deepEqual(imageSize(jpeg), { width: 0x0fc0, height: 0x0bc0 });
|
||||
// Progressive (SOF2) too; a DHT (C4) is not a frame.
|
||||
const dht = [0xff, 0xc4, 0x00, 0x04, 0, 0];
|
||||
const sof2 = [0xff, 0xc2, 0x00, 0x11, 0x08, 0x00, 0x10, 0x00, 0x20];
|
||||
assert.deepEqual(
|
||||
imageSize(bytes([0xff, 0xd8], dht, sof2, new Array(10).fill(0))),
|
||||
{
|
||||
width: 32,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('PNG, WebP (lossy, lossless, extended) and GIF sizes', () => {
|
||||
const png = bytes(
|
||||
[0x89],
|
||||
ascii('PNG'),
|
||||
[0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13],
|
||||
ascii('IHDR'),
|
||||
[0, 0, 0x10, 0, 0, 0, 0x08, 0],
|
||||
);
|
||||
assert.deepEqual(imageSize(png), { width: 4096, height: 2048 });
|
||||
const riff = (chunk, rest) =>
|
||||
bytes(ascii('RIFF'), [0, 0, 0, 0], ascii('WEBP'), ascii(chunk), rest);
|
||||
const vp8 = riff('VP8 ', [...new Array(10).fill(0), 0x00, 0x08, 0x00, 0x04]);
|
||||
assert.deepEqual(imageSize(vp8), { width: 2048, height: 1024 });
|
||||
// VP8L: 14-bit width-1 and height-1 packed after the 0x2f signature.
|
||||
const w = 99;
|
||||
const h = 49;
|
||||
const packed = w | (h << 14);
|
||||
const vp8l = riff('VP8L', [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0x2f,
|
||||
packed & 0xff,
|
||||
(packed >> 8) & 0xff,
|
||||
(packed >> 16) & 0xff,
|
||||
(packed >> 24) & 0xff,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
assert.deepEqual(imageSize(vp8l), { width: 100, height: 50 });
|
||||
const vp8x = riff(
|
||||
'VP8X',
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0x0f, 0x00, 0xff, 0x07, 0x00],
|
||||
);
|
||||
assert.deepEqual(imageSize(vp8x), { width: 4096, height: 2048 });
|
||||
const gif = bytes(ascii('GIF89a'), [0x40, 0x01, 0xf0, 0x00], [0, 0]);
|
||||
assert.deepEqual(imageSize(gif), { width: 320, height: 240 });
|
||||
});
|
||||
|
||||
test('anything else, or a header cut short, has no size', () => {
|
||||
assert.equal(imageSize(null), null);
|
||||
assert.equal(imageSize(new Uint8Array(4)), null);
|
||||
assert.equal(imageSize(bytes(ascii('<html><body>'))), null);
|
||||
assert.equal(
|
||||
imageSize(
|
||||
bytes([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff], new Array(8).fill(0)),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -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,49 @@
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLimiter } from './limiter.js';
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A task that runs until `finish()`. */
|
||||
function held(log, name) {
|
||||
let finish;
|
||||
const done = new Promise((resolve) => (finish = resolve));
|
||||
return {
|
||||
task: () => {
|
||||
log.push(name);
|
||||
return done.then(() => name);
|
||||
},
|
||||
finish: () => finish(),
|
||||
};
|
||||
}
|
||||
|
||||
test('at most `limit` tasks run; queued ones start first in, first out', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(2);
|
||||
const tasks = ['a', 'b', 'c', 'd'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b']);
|
||||
assert.equal(gate.pending, 4);
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b', 'c']);
|
||||
for (const { finish } of tasks) finish();
|
||||
assert.deepEqual(await Promise.all(results), ['a', 'b', 'c', 'd']);
|
||||
assert.equal(gate.pending, 0);
|
||||
});
|
||||
|
||||
test('lifo starts the newest queued task next', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1, { lifo: true });
|
||||
const tasks = ['a', 'b', 'c'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'c']);
|
||||
tasks[2].finish();
|
||||
tasks[1].finish();
|
||||
await Promise.all(results);
|
||||
});
|
||||
|
||||
test('a queued task whose signal aborts never runs, leaves the queue and rejects with the reason', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const controller = new AbortController();
|
||||
const queued = gate.run(held(log, 'b').task, { signal: controller.signal });
|
||||
await tick();
|
||||
assert.equal(gate.pending, 2);
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(queued, /overtaken/);
|
||||
assert.equal(gate.pending, 1, 'out of the queue');
|
||||
first.finish();
|
||||
await running;
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a'], 'b never ran');
|
||||
});
|
||||
|
||||
test('a task already aborted when its turn comes is skipped', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const signal = {
|
||||
aborted: false,
|
||||
reason: new Error('gone'),
|
||||
addEventListener() {},
|
||||
};
|
||||
const queued = gate.run(held(log, 'b').task, { signal });
|
||||
signal.aborted = true;
|
||||
first.finish();
|
||||
await running;
|
||||
await assert.rejects(queued, /gone/);
|
||||
assert.deepEqual(log, ['a']);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,532 @@
|
||||
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,
|
||||
decoded = null,
|
||||
tuning = TUNING,
|
||||
overviewPointSize = null,
|
||||
} = {},
|
||||
) {
|
||||
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;
|
||||
if (decoded) return decoded(centre, tile);
|
||||
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,
|
||||
messages,
|
||||
...(passes ? { passes } : {}),
|
||||
...(overviewPointSize ? { overviewPointSize } : {}),
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
||||
/** Three lines in one tile: an old long one, then two newer short ones. */
|
||||
function threeLines(centre) {
|
||||
const line = (id, capturedAt, span) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - span, centre.lat],
|
||||
[centre.lon + span, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt,
|
||||
isPano: false,
|
||||
});
|
||||
return {
|
||||
sequences: [
|
||||
line('old-long', 1, 0.002),
|
||||
line('new-short', 2, 0.0002),
|
||||
line('newer-short', 3, 0.0001),
|
||||
],
|
||||
overview: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => {
|
||||
const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 };
|
||||
const drawnIds = (state) =>
|
||||
[...state.coverage.tiles.values()].flatMap((tile) => [
|
||||
...tile.sequences.keys(),
|
||||
]);
|
||||
const newest = setup(t, { decoded: threeLines, tuning: capped });
|
||||
newest.coverage.refresh();
|
||||
assert.ok(await until(() => newest.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']);
|
||||
const kept = setup(t, {
|
||||
decoded: threeLines,
|
||||
tuning: { ...capped, keepOrder: true },
|
||||
});
|
||||
kept.coverage.refresh();
|
||||
assert.ok(await until(() => kept.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']);
|
||||
});
|
||||
|
||||
test('overview points take the provider’s size, each its own', async (t) => {
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 3, kind: 'overview' }),
|
||||
tuning: { ...TUNING, overviewMaxTiles: 4 },
|
||||
decoded: (centre) => ({
|
||||
sequences: [],
|
||||
overview: [
|
||||
{ id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 },
|
||||
{ id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 },
|
||||
],
|
||||
}),
|
||||
overviewPointSize: (point) => (point.weight > 1 ? 7 : 3),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size > 0));
|
||||
const sizes = [];
|
||||
for (const primitive of viewer.scene.primitives.items)
|
||||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||||
sizes.push(primitive.get(i).pixelSize);
|
||||
assert.deepEqual(sizes.sort(), [3, 7]);
|
||||
});
|
||||
|
||||
test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => {
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre) => {
|
||||
const line = (id, n) => ({
|
||||
id,
|
||||
parts: [
|
||||
Array.from({ length: n }, (_, i) => [
|
||||
centre.lon - 0.001 + i * 0.0001,
|
||||
centre.lat,
|
||||
]),
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
});
|
||||
return {
|
||||
sequences: [line('three', 3), line('four', 4), line('two', 2)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.deepEqual([...tile.sequences.keys()], ['three', 'two']);
|
||||
});
|
||||
|
||||
/** A line of `n` points, its id unique to the tile. */
|
||||
function pointsLine(id, n, tile, centre) {
|
||||
return {
|
||||
id,
|
||||
parts: [
|
||||
Array.from({ length: n }, (_, i) => [
|
||||
centre.lon - 0.001 + i * 0.00001,
|
||||
centre.lat,
|
||||
]),
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
};
|
||||
}
|
||||
|
||||
test('the point budget is shared: a sparse tile keeps its few, a dense one gets the rest', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage } = setup(t, {
|
||||
// Street zoom far enough in that several tiles are in view.
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning,
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
const lines = dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)];
|
||||
return { sequences: lines, overview: [] };
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const entries = [...state.coverage.tiles.values()];
|
||||
const drawn = (entry) => entry.pointsDrawn;
|
||||
const total = entries.reduce((sum, entry) => sum + drawn(entry), 0);
|
||||
assert.ok(total <= tuning.maxPoints, `within the budget: ${total}`);
|
||||
const sparse = entries.filter((entry) => entry.pointDemand === 2);
|
||||
const dense = entries.filter((entry) => entry.pointDemand === 150);
|
||||
assert.ok(sparse.length && dense.length, 'both kinds in view');
|
||||
for (const entry of sparse)
|
||||
assert.equal(drawn(entry), 2, 'a sparse tile keeps all');
|
||||
const fair = Math.floor(tuning.maxPoints / tuning.maxTiles);
|
||||
const left = tuning.maxPoints - sparse.length * 2;
|
||||
for (const entry of dense)
|
||||
assert.ok(
|
||||
drawn(entry) > fair || dense.length * fair >= left,
|
||||
`a dense tile gets more than the even share: ${drawn(entry)}`,
|
||||
);
|
||||
assert.ok(
|
||||
total > tuning.maxPoints - 5 * dense.length,
|
||||
'little budget unused',
|
||||
);
|
||||
});
|
||||
|
||||
test('a selected sequence the budget left out is drawn, highlighted', async (t) => {
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: [
|
||||
pointsLine('long', 5, tile, centre),
|
||||
pointsLine('short', 3, tile, centre),
|
||||
],
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.deepEqual([...tile.sequences.keys()], ['long']);
|
||||
state.sequence.selectedId = 'short';
|
||||
coverage.recolorSequence('short', true);
|
||||
assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']);
|
||||
});
|
||||
|
||||
/** Count the line primitives a coverage adds to the scene. */
|
||||
function countAdds(viewer) {
|
||||
const counts = { added: 0 };
|
||||
for (const collection of [
|
||||
viewer.scene.groundPrimitives,
|
||||
viewer.scene.primitives,
|
||||
]) {
|
||||
const add = collection.add.bind(collection);
|
||||
collection.add = (primitive) => {
|
||||
counts.added++;
|
||||
return add(primitive);
|
||||
};
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning,
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
return {
|
||||
sequences: dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const redrawn = [...state.coverage.tiles.values()].filter(
|
||||
(entry) => entry.swap,
|
||||
);
|
||||
assert.ok(redrawn.length > 0, 'a dense tile took a bigger share');
|
||||
for (const entry of redrawn)
|
||||
for (const { primitive } of entry.swap.old)
|
||||
assert.ok(
|
||||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||||
viewer.scene.primitives.items.has(primitive),
|
||||
'the old lines are still on the globe while the new ones build',
|
||||
);
|
||||
});
|
||||
|
||||
test('a second redraw while a swap still builds keeps the lines on screen', async (t) => {
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
return {
|
||||
sequences: dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
// A tile mid-swap (its new lines never become ready here) with a line
|
||||
// the cap left out: selecting that line redraws the tile again.
|
||||
const entry = [...state.coverage.tiles.values()].find(
|
||||
(tile) =>
|
||||
tile.swap &&
|
||||
tile.sequenceList.some((sequence) => !tile.sequences.has(sequence.id)),
|
||||
);
|
||||
assert.ok(entry, 'a swapping tile with a line left out');
|
||||
const onScreen = [...entry.swap.old];
|
||||
const building = entry.primitives.map(({ primitive }) => primitive);
|
||||
const left = entry.sequenceList.find(
|
||||
(sequence) => !entry.sequences.has(sequence.id),
|
||||
);
|
||||
state.sequence.selectedId = left.id;
|
||||
coverage.recolorSequence(left.id, true);
|
||||
assert.ok(entry.sequences.has(left.id), 'the selected line is drawn');
|
||||
const onGlobe = (primitive) =>
|
||||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||||
viewer.scene.primitives.items.has(primitive);
|
||||
for (const { primitive } of onScreen)
|
||||
assert.ok(
|
||||
onGlobe(primitive),
|
||||
'the lines on screen stay until the new ones are ready',
|
||||
);
|
||||
for (const primitive of building)
|
||||
assert.ok(!onGlobe(primitive), 'the first swap’s unready lines are gone');
|
||||
assert.ok(entry.swap, 'still swapping');
|
||||
});
|
||||
|
||||
test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => {
|
||||
let pano = 'all';
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: [
|
||||
pointsLine('long', 5, tile, centre),
|
||||
{ ...pointsLine('short', 3, tile, centre), isPano: true },
|
||||
],
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
state.context.getFilter = () => ({ pano, sinceMs: null });
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
state.sequence.selectedId = 'short';
|
||||
coverage.recolorSequence('short', true);
|
||||
const counts = countAdds(viewer);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
coverage.refresh();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(counts.added, 0, 'settles redraw nothing');
|
||||
// A selection the filter hides is not drawn, so it is not redrawn for.
|
||||
pano = 'flat';
|
||||
coverage.rebuild();
|
||||
const after = countAdds(viewer);
|
||||
for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true);
|
||||
assert.equal(after.added, 0);
|
||||
});
|
||||
|
||||
test('small share increases add up: a retained tile redraws once its share has grown past the threshold since its last draw', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 900,
|
||||
maxPoints: 100,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning,
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`l${i}`, 4, tile, centre),
|
||||
),
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size === 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.equal(tile.pointsDrawn, 100);
|
||||
const settle = async () => {
|
||||
coverage.refresh();
|
||||
await until(() => !state.coverage.pending.size);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
};
|
||||
// +8%: under the threshold, nothing is redrawn.
|
||||
tuning.maxPoints = 108;
|
||||
await settle();
|
||||
assert.equal(tile.pointsDrawn, 100);
|
||||
// +8% again: +16% since the tile was last drawn, so it is redrawn.
|
||||
tuning.maxPoints = 116;
|
||||
await settle();
|
||||
assert.equal(tile.pointsDrawn, 116, 'the grown share is drawn');
|
||||
});
|
||||
@@ -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,245 @@
|
||||
/**
|
||||
* A minimal equirectangular panorama renderer on WebGL: one full-screen
|
||||
* triangle whose fragment shader turns each pixel's view ray into texture
|
||||
* coordinates. The image centre is the camera's forward direction; yaw turns
|
||||
* right from it, pitch looks up.
|
||||
*/
|
||||
|
||||
export const PANO_FOV_DEFAULT_DEG = 80;
|
||||
export const PANO_FOV_MIN_DEG = 30;
|
||||
export const PANO_FOV_MAX_DEG = 100;
|
||||
export const PANO_PITCH_LIMIT_DEG = 85;
|
||||
|
||||
const VERTEX = `
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_ndc;
|
||||
void main() {
|
||||
v_ndc = a_position;
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAGMENT = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_image;
|
||||
uniform float u_yaw;
|
||||
uniform float u_pitch;
|
||||
uniform float u_tanHalfFov;
|
||||
uniform float u_aspect;
|
||||
varying vec2 v_ndc;
|
||||
const float PI = 3.141592653589793;
|
||||
void main() {
|
||||
vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0));
|
||||
float cp = cos(u_pitch), sp = sin(u_pitch);
|
||||
ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp);
|
||||
float cy = cos(u_yaw), sy = sin(u_yaw);
|
||||
ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy);
|
||||
float lon = atan(ray.x, -ray.z);
|
||||
float lat = asin(clamp(ray.y, -1.0, 1.0));
|
||||
gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI));
|
||||
}`;
|
||||
|
||||
/** Wrap degrees into [0, 360). */
|
||||
export function wrapDegrees(value) {
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function clampPitch(degrees) {
|
||||
return Math.max(
|
||||
-PANO_PITCH_LIMIT_DEG,
|
||||
Math.min(PANO_PITCH_LIMIT_DEG, degrees),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampFov(degrees) {
|
||||
return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees));
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture coordinates the shader samples for a view ray, for tests and
|
||||
* picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up,
|
||||
* z backward).
|
||||
*/
|
||||
export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) {
|
||||
const p = (pitch * Math.PI) / 180;
|
||||
const w = (yaw * Math.PI) / 180;
|
||||
const length = Math.hypot(ray[0], ray[1], ray[2]) || 1;
|
||||
let [x, y, z] = ray.map((v) => v / length);
|
||||
[y, z] = [
|
||||
y * Math.cos(p) - z * Math.sin(p),
|
||||
y * Math.sin(p) + z * Math.cos(p),
|
||||
];
|
||||
[x, z] = [
|
||||
x * Math.cos(w) - z * Math.sin(w),
|
||||
x * Math.sin(w) + z * Math.cos(w),
|
||||
];
|
||||
const lon = Math.atan2(x, -z);
|
||||
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
|
||||
return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI];
|
||||
}
|
||||
|
||||
function compile(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(shader);
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`Panorama shader failed: ${log}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0;
|
||||
|
||||
/**
|
||||
* A panorama view on `canvas`. Throws when WebGL is unavailable, so the
|
||||
* caller can fall back to a flat picture.
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}}
|
||||
*/
|
||||
export function createPanoramaRenderer(canvas) {
|
||||
const gl =
|
||||
canvas.getContext('webgl', {
|
||||
antialias: false,
|
||||
depth: false,
|
||||
preserveDrawingBuffer: false,
|
||||
}) || canvas.getContext('experimental-webgl');
|
||||
if (!gl) throw new Error('WebGL is unavailable');
|
||||
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
|
||||
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
|
||||
throw new Error('Panorama program failed to link');
|
||||
gl.useProgram(program);
|
||||
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
// One triangle covering the screen.
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 3, -1, -1, 3]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const position = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
const uniforms = Object.fromEntries(
|
||||
['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [
|
||||
name,
|
||||
gl.getUniformLocation(program, name),
|
||||
]),
|
||||
);
|
||||
const texture = gl.createTexture();
|
||||
let loaded = false;
|
||||
let frame = 0;
|
||||
const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG };
|
||||
|
||||
/** Match the drawing buffer to the canvas's laid-out size. */
|
||||
function syncSize() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
syncSize();
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame = 0;
|
||||
if (gl.isContextLost()) return;
|
||||
// Every frame: the canvas may have been laid out since the last one.
|
||||
syncSize();
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (!loaded) return;
|
||||
gl.uniform1i(uniforms.u_image, 0);
|
||||
gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180);
|
||||
gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180);
|
||||
gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360));
|
||||
gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height));
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
return {
|
||||
setImage(image) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
// RGBA: the format a decoded ImageBitmap uploads without conversion.
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
image,
|
||||
);
|
||||
// Read before the close: a closed bitmap measures 0×0.
|
||||
const { width, height } = image;
|
||||
// The pixels are on the GPU now; a bitmap's copy can go.
|
||||
image.close?.();
|
||||
// Linear only: a mipmapped lookup draws a seam where longitude wraps.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
const repeat =
|
||||
isPowerOfTwo(width) && isPowerOfTwo(height)
|
||||
? gl.REPEAT
|
||||
: gl.CLAMP_TO_EDGE;
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
loaded = true;
|
||||
resize();
|
||||
scheduleRender();
|
||||
},
|
||||
setView(next = {}) {
|
||||
if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw);
|
||||
if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch);
|
||||
if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov);
|
||||
scheduleRender();
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize,
|
||||
render: scheduleRender,
|
||||
hasImage: () => loaded,
|
||||
clear() {
|
||||
loaded = false;
|
||||
// Hand the 32 MB texture back: a closed viewer keeps only 1×1.
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array(4),
|
||||
);
|
||||
scheduleRender();
|
||||
},
|
||||
destroy() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
loaded = false;
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PANO_FOV_MAX_DEG,
|
||||
PANO_FOV_MIN_DEG,
|
||||
PANO_PITCH_LIMIT_DEG,
|
||||
clampFov,
|
||||
clampPitch,
|
||||
createPanoramaRenderer,
|
||||
rayToUv,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
|
||||
const close = (actual, expected) =>
|
||||
actual.forEach((value, i) =>
|
||||
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`),
|
||||
);
|
||||
|
||||
test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => {
|
||||
close(rayToUv([0, 0, -1]), [0.5, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]);
|
||||
close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]);
|
||||
close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]);
|
||||
// Pitch first, then yaw: looking up-right stays up.
|
||||
close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]);
|
||||
// A ray to the right of the screen is the same as turning right.
|
||||
close(rayToUv([1, 0, 0]), [0.75, 0.5]);
|
||||
});
|
||||
|
||||
test('view limits: pitch stops short of the poles, field of view stays usable', () => {
|
||||
assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG);
|
||||
assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG);
|
||||
assert.equal(clampFov(5), PANO_FOV_MIN_DEG);
|
||||
assert.equal(clampFov(170), PANO_FOV_MAX_DEG);
|
||||
assert.equal(wrapDegrees(-30), 330);
|
||||
assert.equal(wrapDegrees(725), 5);
|
||||
});
|
||||
|
||||
/**
|
||||
* A WebGL stand-in that records draw calls and uniform values; `lost` makes
|
||||
* it report a lost context.
|
||||
*/
|
||||
function stubGl() {
|
||||
const gl = {
|
||||
lost: false,
|
||||
calls: [],
|
||||
uniforms: {},
|
||||
isContextLost: () => gl.lost,
|
||||
getShaderParameter: () => true,
|
||||
getProgramParameter: () => true,
|
||||
getUniformLocation: (program, name) => name,
|
||||
getExtension: () => null,
|
||||
uniform1f: (name, value) => (gl.uniforms[name] = value),
|
||||
};
|
||||
return new Proxy(gl, {
|
||||
get(target, key) {
|
||||
if (key in target) return target[key];
|
||||
if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key;
|
||||
return (...args) => {
|
||||
target.calls.push(key);
|
||||
return { key, args };
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A panorama on a stub canvas, with frames run when the test says. */
|
||||
function stubPanorama(t, { width = 400, height = 250 } = {}) {
|
||||
const gl = stubGl();
|
||||
const canvas = {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
width: 300,
|
||||
height: 150,
|
||||
getContext: () => gl,
|
||||
};
|
||||
const frames = [];
|
||||
const saved = {
|
||||
request: globalThis.requestAnimationFrame,
|
||||
cancel: globalThis.cancelAnimationFrame,
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
globalThis.cancelAnimationFrame = () => {};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = saved.request;
|
||||
globalThis.cancelAnimationFrame = saved.cancel;
|
||||
});
|
||||
const renderer = createPanoramaRenderer(canvas);
|
||||
const runFrames = () => {
|
||||
while (frames.length) frames.shift()();
|
||||
};
|
||||
return { gl, canvas, renderer, runFrames };
|
||||
}
|
||||
|
||||
const IMAGE = { width: 1024, height: 512 };
|
||||
|
||||
test('a panorama draws once it has an image; looking up and right are positive angles', (t) => {
|
||||
const { gl, renderer, runFrames } = stubPanorama(t);
|
||||
renderer.render();
|
||||
runFrames();
|
||||
assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet');
|
||||
renderer.setImage(IMAGE);
|
||||
renderer.setView({ yaw: 30, pitch: 30 });
|
||||
runFrames();
|
||||
assert.ok(gl.calls.includes('drawArrays'));
|
||||
assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`);
|
||||
assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9);
|
||||
assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9);
|
||||
renderer.setView({ pitch: -20 });
|
||||
runFrames();
|
||||
assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative');
|
||||
});
|
||||
|
||||
test('a lost WebGL context draws nothing', (t) => {
|
||||
const { gl, renderer, runFrames } = stubPanorama(t);
|
||||
renderer.setImage(IMAGE);
|
||||
gl.lost = true;
|
||||
gl.calls.length = 0;
|
||||
runFrames();
|
||||
assert.deepEqual(
|
||||
gl.calls.filter((call) =>
|
||||
['viewport', 'clear', 'drawArrays'].includes(call),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('the canvas is measured again when it draws, not only when its image arrived', (t) => {
|
||||
// The image arrives while the canvas is still hidden (0×0)…
|
||||
const { canvas, renderer, runFrames } = stubPanorama(t, {
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
renderer.setImage(IMAGE);
|
||||
assert.deepEqual([canvas.width, canvas.height], [1, 1]);
|
||||
// …and is laid out before the frame draws.
|
||||
canvas.clientWidth = 400;
|
||||
canvas.clientHeight = 250;
|
||||
runFrames();
|
||||
assert.deepEqual([canvas.width, canvas.height], [400, 250]);
|
||||
});
|
||||
|
||||
test('an image uploads as RGBA and its bitmap is released; closing hands the texture back as 1×1', (t) => {
|
||||
const { gl, renderer } = stubPanorama(t);
|
||||
const uploads = [];
|
||||
gl.texImage2D = (...args) => uploads.push(args);
|
||||
let closed = false;
|
||||
renderer.setImage({
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close: () => (closed = true),
|
||||
});
|
||||
assert.equal(uploads.length, 1);
|
||||
assert.equal(
|
||||
uploads[0][2],
|
||||
'RGBA',
|
||||
'the format a decoded bitmap uploads without conversion',
|
||||
);
|
||||
assert.equal(closed, true, 'the decoded copy is freed once on the GPU');
|
||||
renderer.clear();
|
||||
const last = uploads.at(-1);
|
||||
assert.deepEqual(
|
||||
[last[3], last[4]],
|
||||
[1, 1],
|
||||
'a closed viewer keeps a 1×1 texture',
|
||||
);
|
||||
assert.equal(renderer.hasImage(), false);
|
||||
});
|
||||
|
||||
test('a power-of-two bitmap wraps in longitude even though it is closed once uploaded', (t) => {
|
||||
const { gl, renderer } = stubPanorama(t);
|
||||
const wraps = [];
|
||||
gl.texParameteri = (target, name, value) => wraps.push([name, value]);
|
||||
gl.TEXTURE_WRAP_S = 'WRAP_S';
|
||||
gl.REPEAT = 'REPEAT';
|
||||
gl.CLAMP_TO_EDGE = 'CLAMP';
|
||||
const bitmap = {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
// A closed ImageBitmap measures 0×0.
|
||||
close() {
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
},
|
||||
};
|
||||
renderer.setImage(bitmap);
|
||||
assert.deepEqual(
|
||||
wraps.find(([name]) => name === 'WRAP_S'),
|
||||
['WRAP_S', 'REPEAT'],
|
||||
);
|
||||
wraps.length = 0;
|
||||
renderer.setImage({ width: 2000, height: 1000 });
|
||||
assert.deepEqual(
|
||||
wraps.find(([name]) => name === 'WRAP_S'),
|
||||
['WRAP_S', 'CLAMP'],
|
||||
'other sizes clamp (WebGL 1 repeats powers of two only)',
|
||||
);
|
||||
});
|
||||
@@ -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,922 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { imageSize } from './imageSize.js';
|
||||
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** A picture file or decoded side past these is refused (a photo is a few MB). */
|
||||
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
|
||||
export const MAX_IMAGE_SIDE = 16_384;
|
||||
/** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */
|
||||
export const MAX_IMAGE_PIXELS = 8_192 * 8_192;
|
||||
/** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */
|
||||
const HEADER_BYTES = 256 * 1024;
|
||||
|
||||
/** Whether a picture this size is past what the viewer decodes. */
|
||||
export function tooManyPixels(width, height) {
|
||||
return (
|
||||
width > MAX_IMAGE_SIDE ||
|
||||
height > MAX_IMAGE_SIDE ||
|
||||
width * height > MAX_IMAGE_PIXELS
|
||||
);
|
||||
}
|
||||
|
||||
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
|
||||
/**
|
||||
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
* off the main thread into an ImageBitmap where the browser can: uploading
|
||||
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
|
||||
* 4096×2048 on a phone-class CPU). `resize` shrinks a bitmap while decoding
|
||||
* it (a panorama larger than any texture the viewer uploads).
|
||||
* @param {string} url
|
||||
* @param {{crossOrigin?: boolean, timeoutMs?: number, resize?: [number, number]|null, failure?: string}} [options]
|
||||
*/
|
||||
export function loadImage(
|
||||
url,
|
||||
{
|
||||
crossOrigin = true,
|
||||
timeoutMs = IMAGE_TIMEOUT_MS,
|
||||
resize = null,
|
||||
failure = 'The photo could not be loaded',
|
||||
signal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
crossOrigin &&
|
||||
url &&
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs, resize, failure, signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(failure));
|
||||
if (signal?.aborted) return reject(new Error(failure));
|
||||
const image = new Image();
|
||||
if (crossOrigin) image.crossOrigin = 'anonymous';
|
||||
image.decoding = 'async';
|
||||
// Stop the download: another photo opened, or the time ran out.
|
||||
const stop = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', stop);
|
||||
image.onload = image.onerror = null;
|
||||
image.src = '';
|
||||
reject(new Error(failure));
|
||||
};
|
||||
const timer = setTimeout(stop, timeoutMs);
|
||||
signal?.addEventListener?.('abort', stop, { once: true });
|
||||
const settle = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', stop);
|
||||
};
|
||||
image.onload = () => {
|
||||
settle();
|
||||
// An <img> reads no header first: refuse what it decoded too large.
|
||||
if (tooManyPixels(image.naturalWidth || 0, image.naturalHeight || 0)) {
|
||||
image.src = '';
|
||||
reject(oversized(failure));
|
||||
return;
|
||||
}
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
settle();
|
||||
reject(new Error(failure));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs, resize, failure, signal) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// The caller's abort (the photo changed) stops a multi-megabyte download.
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(failure);
|
||||
// Picture hosts are not ours: refuse a file or an image past any real
|
||||
// photo's size before it is decoded.
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_IMAGE_BYTES) throw oversized(failure);
|
||||
const blob = await boundedBlob(response, failure);
|
||||
// Its declared size, before anything decodes it.
|
||||
const size = imageSize(
|
||||
new Uint8Array(await blob.slice(0, HEADER_BYTES).arrayBuffer()),
|
||||
);
|
||||
if (size && tooManyPixels(size.width, size.height))
|
||||
throw oversized(failure);
|
||||
const bitmap = await (resize
|
||||
? createImageBitmap(blob, {
|
||||
resizeWidth: resize[0],
|
||||
resizeHeight: resize[1],
|
||||
resizeQuality: 'high',
|
||||
})
|
||||
: createImageBitmap(blob));
|
||||
// (A resized decode reports its new size: this guards unresized ones.)
|
||||
if (tooManyPixels(bitmap.width, bitmap.height)) {
|
||||
bitmap.close?.();
|
||||
throw oversized(failure);
|
||||
}
|
||||
return bitmap;
|
||||
} catch (error) {
|
||||
// A size refusal stays one: the viewer must not fetch it another way.
|
||||
if (error?.tooLarge) throw error;
|
||||
throw new Error(failure);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture refused for its size (`tooLarge`): the viewer will not load
|
||||
* that file another way, an <img> having no size limit at all.
|
||||
*/
|
||||
export function oversized(failure) {
|
||||
return Object.assign(new Error(failure), { tooLarge: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as a Blob, read with a running count so a host that sends no
|
||||
* length (or a false one) cannot fill memory before the cap is checked.
|
||||
*/
|
||||
async function boundedBlob(response, failure) {
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const blob = await response.blob();
|
||||
if (blob.size > MAX_IMAGE_BYTES) throw oversized(failure);
|
||||
return blob;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw oversized(failure);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return new Blob(chunks, {
|
||||
type: response.headers?.get?.('content-type') || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
/** The panorama size a canvas this wide can show sharply at this field of view. */
|
||||
export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
|
||||
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PhotoSources Where a provider's photo comes from.
|
||||
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
|
||||
* @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst
|
||||
* The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to.
|
||||
* @property {(photo: object, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
|
||||
* A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is.
|
||||
* @property {(photo: object, view: {expanded: boolean}) => Array<string>} flatFirst Flat versions to try in turn (`expanded` while the viewer is).
|
||||
* @property {(photo: object, pixels: number) => string|null} flatSharp
|
||||
* A sharper flat version for a picture this many device pixels wide, or null.
|
||||
* @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe
|
||||
* @property {(photo: object) => number|null} [bearingOf] Where the camera looked (default `photo.heading`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Viewer adapter for providers whose photos are plain image files: a flat
|
||||
* photo in an <img>, a 360° photo on a WebGL panorama (drag to look, wheel
|
||||
* or pinch to zoom), and ‹ › to step along the sequence (also ← →). Poses
|
||||
* follow the view direction.
|
||||
*
|
||||
* Photos are normalized by the provider: `{id, sequenceId, lon, lat,
|
||||
* heading, isPano, capturedAt}` plus whatever its `sources` read.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.providerId
|
||||
* @param {string} options.name Provider name for labels and messages, e.g. 'KartaView'.
|
||||
* @param {PhotoSources} options.sources
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo.
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {(photo: object) => void} [options.onShowing] The photo this open shows, just before its pose.
|
||||
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
|
||||
* @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
* @returns {import('./registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createPhotoViewer({
|
||||
providerId,
|
||||
name,
|
||||
sources,
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
onShowing = () => {},
|
||||
onReopen = onStep,
|
||||
onEscape = () => false,
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
}) {
|
||||
const LOAD_FAILED = `The ${name} photo could not be loaded`;
|
||||
const listeners = new Set();
|
||||
let root = null;
|
||||
let picture = null;
|
||||
let canvas = null;
|
||||
let prevButton = null;
|
||||
let nextButton = null;
|
||||
let renderer = null;
|
||||
/** WebGL failed once: 360° photos show their front view instead. */
|
||||
let rendererFailed = false;
|
||||
let photo = null;
|
||||
/** What shows: 'flat', 'pano' or null. */
|
||||
let showing = null;
|
||||
let openSeq = 0;
|
||||
let renderMode = 'letterbox';
|
||||
let drag = null;
|
||||
let poseFrame = 0;
|
||||
let removeListeners = [];
|
||||
/** The document the viewer was built in (new canvases come from it). */
|
||||
let documentOf = null;
|
||||
/** A context loss's look direction, for the reopen of that photo. */
|
||||
let restoreView = null;
|
||||
/** The panel's viewer box, where Esc is listened for (null: the root). */
|
||||
let escapeTarget = null;
|
||||
/** Width of the panorama texture on screen (0: the preview). */
|
||||
let panoWidth = 0;
|
||||
/** The sharper panorama on its way, for which open (resizes come in bursts). */
|
||||
let panoComing = null;
|
||||
/** A resize came while it loaded: look again once it lands. */
|
||||
let lookAgain = false;
|
||||
/** The sharper flat version on screen, and the one on its way. */
|
||||
let flatSharp = { shown: null, loading: null };
|
||||
/** The open in progress: another open, a close or an unmount stops its downloads. */
|
||||
let openAbort = null;
|
||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||
let openingId = null;
|
||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||
let keepViewFor = null;
|
||||
/** ‹ › presses run one after another, each from where the last one went. */
|
||||
let steps = Promise.resolve();
|
||||
|
||||
/** Files refused for their size: never loaded again, by any path. */
|
||||
const refused = new Set();
|
||||
async function loadAs(url, options) {
|
||||
if (refused.has(url)) throw oversized(LOAD_FAILED);
|
||||
try {
|
||||
return await load(url, { failure: LOAD_FAILED, ...options });
|
||||
} catch (error) {
|
||||
if (error?.tooLarge) refused.add(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.(`${providerId}-viewer`);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
// A lost GPU context, still being recovered: the open that redraws the
|
||||
// panorama poses once it is on screen.
|
||||
if (showing === 'pano' && !renderer) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = sources.bearingOf
|
||||
? sources.bearingOf(photo)
|
||||
: photo.heading;
|
||||
const described = sources.describe(photo);
|
||||
const pose = {
|
||||
providerId,
|
||||
imageId: photo.id,
|
||||
position: { lon: photo.lon, lat: photo.lat },
|
||||
bearing:
|
||||
heading === null || heading === undefined
|
||||
? null
|
||||
: view
|
||||
? wrapDegrees(heading + view.yaw)
|
||||
: heading,
|
||||
tilt: view ? view.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: showing === 'pano',
|
||||
capturedAt: photo.capturedAt,
|
||||
capturedAtPrecision: described.capturedAtPrecision || 'day',
|
||||
title: described.title ?? null,
|
||||
creator: described.creator ?? null,
|
||||
license: described.license ?? null,
|
||||
sequenceId: photo.sequenceId,
|
||||
externalUrl: described.externalUrl,
|
||||
};
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Re-emit on the next frame: a drag moves the view many times a frame. */
|
||||
function emitSoon() {
|
||||
if (poseFrame) return;
|
||||
poseFrame = requestAnimationFrame(() => {
|
||||
poseFrame = 0;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function syncButtons() {
|
||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||
if (prevButton) prevButton.disabled = !enabled;
|
||||
if (nextButton) nextButton.disabled = !enabled;
|
||||
if (enabled) checkEnds(photo);
|
||||
}
|
||||
|
||||
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
|
||||
function checkEnds(target) {
|
||||
for (const [delta, button] of [
|
||||
[-1, prevButton],
|
||||
[1, nextButton],
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) disable(button);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Disable a step button, keeping keyboard focus in the viewer. */
|
||||
function disable(button) {
|
||||
if (button.ownerDocument?.activeElement === button) root?.focus?.();
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
showing = kind;
|
||||
if (picture) picture.hidden = kind !== 'flat';
|
||||
if (canvas) canvas.hidden = kind !== 'pano';
|
||||
if (root) root.dataset.kind = kind || '';
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
function applyRenderMode() {
|
||||
if (picture)
|
||||
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
|
||||
}
|
||||
|
||||
function ensureRenderer() {
|
||||
if (renderer || rendererFailed || !canvas) return renderer;
|
||||
try {
|
||||
renderer = createRenderer(canvas);
|
||||
} catch {
|
||||
rendererFailed = true;
|
||||
renderer = null;
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
|
||||
* with it: drop the renderer, and show the photo again on a new one.
|
||||
*/
|
||||
function onContextLost(event) {
|
||||
event.preventDefault?.();
|
||||
const lost = renderer;
|
||||
renderer = null;
|
||||
// The new renderer starts facing the photo's heading: keep the user's look.
|
||||
if (showing === 'pano' && photo && lost)
|
||||
restoreView = { id: photo.id, view: lost.getView() };
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// A lost canvas hands back its lost context: draw on a new element.
|
||||
const fresh = makeCanvas(documentOf);
|
||||
fresh.hidden = canvas.hidden;
|
||||
canvas.replaceWith?.(fresh);
|
||||
canvas = fresh;
|
||||
// Show the photo again through the core, unless a newer one is opening.
|
||||
if (showing === 'pano' && photo && openingId === null) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
openingId = again;
|
||||
onReopen(again);
|
||||
}
|
||||
}
|
||||
|
||||
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
|
||||
function makeCanvas(doc) {
|
||||
const element = doc.createElement('canvas');
|
||||
element.className = 'sl-photo-pano';
|
||||
element.hidden = true;
|
||||
listen(element, 'pointerdown', onPointerDown);
|
||||
listen(element, 'pointermove', onPointerMove);
|
||||
listen(element, 'pointerup', onPointerUp);
|
||||
listen(element, 'pointercancel', onPointerUp);
|
||||
listen(element, 'wheel', onWheel, { passive: false });
|
||||
listen(element, 'webglcontextlost', onContextLost);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
*/
|
||||
function stepBy(delta) {
|
||||
steps = steps.then(async () => {
|
||||
const fromId = openingId ?? photo?.id;
|
||||
if (!fromId || !root) return;
|
||||
let from;
|
||||
let nextId = null;
|
||||
try {
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
// Not the end of the sequence, just no answer: the press is lost.
|
||||
return;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
keepViewFor = nextId;
|
||||
onStep(nextId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
|
||||
const pointers = new Map();
|
||||
let pinch = null;
|
||||
|
||||
function pinchSpan() {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
if (pointers.size === 2) {
|
||||
drag = null;
|
||||
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
|
||||
return;
|
||||
}
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.classList.add('sl-photo-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (pointers.has(event.pointerId))
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && pointers.size === 2 && renderer) {
|
||||
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.id || !renderer) return;
|
||||
const { fov } = drag.view;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const vertical = fov;
|
||||
const horizontal = fov * (width / height);
|
||||
renderer.setView({
|
||||
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
|
||||
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
|
||||
});
|
||||
emitSoon();
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-photo-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
|
||||
* caption) is the provider's before the panel's, which would collapse;
|
||||
* expanded, the dialog's own Esc shrinks it.
|
||||
*/
|
||||
function onEscapeKey(event) {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
event.shiftKey ||
|
||||
event.defaultPrevented ||
|
||||
root?.closest?.('.sl-viewer-wrap-expanded') ||
|
||||
!onEscape()
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (!escapeTarget) onEscapeKey(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
if (turn && event.shiftKey && pano) {
|
||||
event.preventDefault();
|
||||
const { yaw, fov } = renderer.getView();
|
||||
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
|
||||
emitSoon();
|
||||
} else if (turn) {
|
||||
event.preventDefault();
|
||||
stepBy(turn);
|
||||
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
|
||||
event.preventDefault();
|
||||
const { pitch, fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
|
||||
});
|
||||
emitSoon();
|
||||
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
removeListeners.push(() =>
|
||||
target.removeEventListener(type, handler, options),
|
||||
);
|
||||
}
|
||||
|
||||
function build(host) {
|
||||
const doc = host.ownerDocument;
|
||||
documentOf = doc;
|
||||
root = doc.createElement('div');
|
||||
root.className = 'sl-photo-viewer';
|
||||
root.dataset.provider = providerId;
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
`${name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom`,
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-photo-flat';
|
||||
picture.alt = `${name} street-level photo`;
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
const nav = (label, glyph, delta) => {
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `sl-photo-step sl-photo-step-${delta < 0 ? 'prev' : 'next'}`;
|
||||
button.textContent = glyph;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.disabled = true;
|
||||
listen(button, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
stepBy(delta);
|
||||
});
|
||||
return button;
|
||||
};
|
||||
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
|
||||
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
||||
root.append(picture, canvas, prevButton, nextButton);
|
||||
host.append(root);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
// The viewer's box in the panel; without one, the root's own handler
|
||||
// takes Esc (one listener per element and type).
|
||||
escapeTarget = host.closest?.('.sl-viewer-wrap') ?? null;
|
||||
if (escapeTarget) listen(escapeTarget, 'keydown', onEscapeKey);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
poseFrame = 0;
|
||||
drag = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
root?.remove();
|
||||
root = picture = canvas = prevButton = nextButton = null;
|
||||
showing = null;
|
||||
photo = null;
|
||||
}
|
||||
|
||||
/** Device pixels across the flat photo's frame (its longer side). */
|
||||
function viewerPixels() {
|
||||
const ratio = globalThis.devicePixelRatio || 1;
|
||||
const element = showing === 'pano' ? canvas : picture;
|
||||
const side = Math.max(
|
||||
element?.clientWidth || 0,
|
||||
element?.clientHeight || 0,
|
||||
);
|
||||
return (side || root?.clientWidth || 0) * ratio;
|
||||
}
|
||||
|
||||
/** A sharper version once the first is on screen, if nothing replaced it. */
|
||||
async function upgrade(target, seq, kind) {
|
||||
// A 360° photo shown flat (no WebGL) keeps its front view.
|
||||
if (kind === 'flat' && target.isPano) return;
|
||||
// Another photo is opening: the one still shown is not worth more bytes.
|
||||
if (openingId !== null && openingId !== target.id) return;
|
||||
const signal = openAbort?.signal;
|
||||
if (kind === 'pano') {
|
||||
const size = panoSizeNow();
|
||||
if (size[0] <= panoWidth) return;
|
||||
// Resizes come in bursts while the panel animates: one load at a time
|
||||
// for this photo, and one more look once it lands.
|
||||
if (panoComing?.seq === seq) {
|
||||
lookAgain = true;
|
||||
return;
|
||||
}
|
||||
const sharp = sources.panoSharp(target, size, {
|
||||
expanded: isExpanded(),
|
||||
});
|
||||
if (!sharp?.url) return;
|
||||
const coming = { seq, width: size[0], controller: new AbortController() };
|
||||
panoComing = coming;
|
||||
lookAgain = false;
|
||||
try {
|
||||
const image = await loadAs(sharp.url, {
|
||||
crossOrigin: true,
|
||||
signal: coming.controller.signal,
|
||||
resize: sharp.resize ?? null,
|
||||
...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}),
|
||||
});
|
||||
if (seq !== openSeq || photo !== target || showing !== 'pano') {
|
||||
image.close?.();
|
||||
return;
|
||||
}
|
||||
if (!renderer || size[0] <= panoWidth) {
|
||||
image.close?.();
|
||||
return;
|
||||
}
|
||||
renderer.setImage(image);
|
||||
panoWidth = size[0];
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
} finally {
|
||||
if (panoComing === coming) {
|
||||
panoComing = null;
|
||||
if (lookAgain && seq === openSeq && photo === target) {
|
||||
lookAgain = false;
|
||||
upgrade(target, seq, 'pano');
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const url = sources.flatSharp(target, viewerPixels());
|
||||
if (!url || url === flatSharp.shown || url === flatSharp.loading) return;
|
||||
const sharp = flatSharp;
|
||||
sharp.loading = url;
|
||||
try {
|
||||
const image = await loadAs(url, { crossOrigin: false, signal });
|
||||
if (seq !== openSeq || photo !== target || showing !== 'flat') return;
|
||||
picture.src = image.src;
|
||||
sharp.shown = url;
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays; a later resize may try again */
|
||||
} finally {
|
||||
// Discarded or failed: nothing is on its way, so it may be asked again.
|
||||
if (sharp.loading === url) sharp.loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewer is the expanded dialog, not docked in the panel. */
|
||||
function isExpanded() {
|
||||
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
|
||||
}
|
||||
|
||||
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
||||
function dropSharpPanorama() {
|
||||
panoComing?.controller?.abort();
|
||||
panoComing = null;
|
||||
lookAgain = false;
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
function panoSizeNow() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = (canvas?.clientWidth || 0) * ratio;
|
||||
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView, signal) {
|
||||
// If the panorama fails, the caller shows the front view rather than
|
||||
// waiting on it twice.
|
||||
const first = sources.panoFirst(target);
|
||||
if (!first?.url) throw new Error(LOAD_FAILED);
|
||||
const image = await loadAs(first.url, {
|
||||
crossOrigin: true,
|
||||
signal,
|
||||
// Never decoded at more than the texture it becomes.
|
||||
resize: first.resize ?? PANO_MID_SIZE,
|
||||
});
|
||||
if (seq !== openSeq) {
|
||||
image.close?.();
|
||||
return false;
|
||||
}
|
||||
// The context may have been lost while this loaded: build on the new
|
||||
// canvas, or let the caller show the front view.
|
||||
if (!ensureRenderer()) {
|
||||
image.close?.();
|
||||
throw new Error(LOAD_FAILED);
|
||||
}
|
||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||
restoreView = null;
|
||||
if (restore) renderer.setView(restore);
|
||||
else if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
panoWidth = first.width || 0;
|
||||
// A sharper version of the photo before was dropped by `open`; one of
|
||||
// this photo, asked by a resize meanwhile, is still worth having.
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq, signal) {
|
||||
const urls = sources
|
||||
.flatFirst(target, { expanded: isExpanded() })
|
||||
.filter(Boolean);
|
||||
let image;
|
||||
let failure = null;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
image = await loadAs(url, { crossOrigin: false, signal });
|
||||
break;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
if (seq !== openSeq) return false;
|
||||
}
|
||||
}
|
||||
if (!image) throw failure || new Error(LOAD_FAILED);
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
photo = target;
|
||||
show('flat');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openPhoto(id, seq, keepView, signal) {
|
||||
const target = await getPhoto(id, { signal });
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (sources.hasPano(target) && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView, signal);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq, signal);
|
||||
}
|
||||
} else shown = await showFlat(target, seq, signal);
|
||||
if (!shown) return;
|
||||
onShowing(target);
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId) {
|
||||
if (!root) throw new Error(`${name} viewer is not mounted`);
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
dropSharpPanorama();
|
||||
// The previous open's downloads stop: only this photo is wanted now.
|
||||
openAbort?.abort();
|
||||
const opening = (openAbort = new AbortController());
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
try {
|
||||
await openPhoto(id, seq, keepView, opening.signal);
|
||||
} catch (error) {
|
||||
// A later open, or a close, stopped this one: that is not a failure.
|
||||
if (seq !== openSeq) return;
|
||||
throw error;
|
||||
} finally {
|
||||
if (openingId === id && seq === openSeq) openingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openAbort?.abort();
|
||||
openAbort = null;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openAbort?.abort();
|
||||
openAbort = null;
|
||||
refused.clear();
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
// Expanded: a docked viewer's panorama or flat version is now too soft.
|
||||
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
|
||||
upgrade(photo, openSeq, 'pano');
|
||||
else if (showing === 'flat' && photo) upgrade(photo, openSeq, 'flat');
|
||||
},
|
||||
|
||||
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
|
||||
supportsRenderModes: () => showing !== 'pano',
|
||||
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
/** The photo's direction became known (its drive loaded): pose again. */
|
||||
refreshPose() {
|
||||
emit();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPhotoViewer } from './photoViewer.js';
|
||||
import { fakeHost, until } from '../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
const PHOTOS = {
|
||||
a: { id: 'a', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
|
||||
b: { id: 'b', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
|
||||
f: { id: 'f', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false },
|
||||
g: { id: 'g', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false },
|
||||
};
|
||||
|
||||
/** Sources whose panoramas and sharper flat versions are named after the photo. */
|
||||
const SOURCES = {
|
||||
hasPano: (photo) => photo.isPano,
|
||||
panoFirst: (photo) => ({ url: `${photo.id}-first`, width: 2048 }),
|
||||
panoSharp: (photo) => ({ url: `${photo.id}-sharp` }),
|
||||
flatFirst: (photo) => [`${photo.id}-first`],
|
||||
flatSharp: (photo, pixels) => (pixels > 1000 ? `${photo.id}-sharp` : null),
|
||||
describe: () => ({ externalUrl: 'https://example.org' }),
|
||||
};
|
||||
|
||||
/** A viewer whose loads wait until `release(url)` (or fail with `fail(url)`). */
|
||||
function setup() {
|
||||
const pending = new Map();
|
||||
const loads = [];
|
||||
const images = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
loads.push(url);
|
||||
pending.set(url, { resolve, reject });
|
||||
}),
|
||||
createRenderer: () => ({
|
||||
setImage: (image) => images.push(image.src),
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
const release = (url) => {
|
||||
const closed = { value: false };
|
||||
pending.get(url).resolve({ src: url, close: () => (closed.value = true) });
|
||||
pending.delete(url);
|
||||
return closed;
|
||||
};
|
||||
const fail = (url) => {
|
||||
pending.get(url).reject(new Error('gone'));
|
||||
pending.delete(url);
|
||||
};
|
||||
return { viewer, loads, images, release, fail, host: fakeHost() };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const parts = (host) => {
|
||||
const [picture, canvas] = host.children[0].children;
|
||||
return { picture, canvas };
|
||||
};
|
||||
|
||||
test('a panorama that arrives after another photo opened is released, not shown', async () => {
|
||||
const env = setup();
|
||||
await env.viewer.mount(env.host);
|
||||
const first = env.viewer.open('a');
|
||||
await tick();
|
||||
const second = env.viewer.open('b');
|
||||
await tick();
|
||||
const stale = env.release('a-first');
|
||||
await first;
|
||||
assert.equal(stale.value, true, 'its bitmap is closed');
|
||||
env.release('b-first');
|
||||
await second;
|
||||
assert.deepEqual(env.images, ['b-first']);
|
||||
});
|
||||
|
||||
test('one sharp panorama download per open, even across a quick switch and a burst of resizes', async (t) => {
|
||||
const env = setup();
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
await env.viewer.mount(env.host);
|
||||
const { canvas } = parts(env.host);
|
||||
canvas.clientWidth = 1800;
|
||||
const opening = env.viewer.open('a');
|
||||
await tick();
|
||||
env.release('a-first');
|
||||
await opening;
|
||||
await tick();
|
||||
assert.ok(env.loads.includes('a-sharp'), 'expanded: the sharp one is asked');
|
||||
// Photo b opens while a's sharp version is still on its way.
|
||||
const next = env.viewer.open('b');
|
||||
await tick();
|
||||
env.release('b-first');
|
||||
await next;
|
||||
await tick();
|
||||
assert.ok(env.loads.includes('b-sharp'));
|
||||
// a's download finishes late; resizes must not ask for b's again.
|
||||
env.release('a-sharp');
|
||||
await tick();
|
||||
env.viewer.resize();
|
||||
env.viewer.resize();
|
||||
await tick();
|
||||
assert.equal(env.loads.filter((url) => url === 'b-sharp').length, 1);
|
||||
env.release('b-sharp');
|
||||
await tick();
|
||||
assert.deepEqual(env.images, ['a-first', 'b-first', 'b-sharp']);
|
||||
});
|
||||
|
||||
test('a sharper flat version that failed is tried again on the next resize', async (t) => {
|
||||
const env = setup();
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
await env.viewer.mount(env.host);
|
||||
const { picture } = parts(env.host);
|
||||
picture.clientWidth = 1200;
|
||||
const opening = env.viewer.open('f');
|
||||
await tick();
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
await tick();
|
||||
env.fail('f-sharp');
|
||||
await tick();
|
||||
assert.equal(picture.src, 'f-first');
|
||||
env.viewer.resize();
|
||||
await tick();
|
||||
assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 2);
|
||||
env.release('f-sharp');
|
||||
assert.ok(await until(() => picture.src === 'f-sharp'));
|
||||
});
|
||||
|
||||
test('Esc: the docked viewer offers it to the provider first; expanded, it is the dialog’s', async () => {
|
||||
let asked = 0;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
onEscape: () => ++asked > 0,
|
||||
load: async (url) => ({ src: url }),
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
const root = host.children[0];
|
||||
const press = () => {
|
||||
const event = { key: 'Escape', prevented: false, stopped: false };
|
||||
event.preventDefault = () => (event.prevented = true);
|
||||
event.stopPropagation = () => (event.stopped = true);
|
||||
root.fire('keydown', event);
|
||||
return event;
|
||||
};
|
||||
const docked = press();
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(docked.prevented && docked.stopped, true);
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
const expanded = press();
|
||||
assert.equal(asked, 1, 'not asked while expanded');
|
||||
assert.equal(expanded.prevented, false);
|
||||
});
|
||||
|
||||
test('a sharp panorama still downloading when another photo opens is aborted', async (t) => {
|
||||
const signals = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url, options) =>
|
||||
url.endsWith('-sharp')
|
||||
? new Promise((_, reject) => {
|
||||
signals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
})
|
||||
: Promise.resolve({ src: url }),
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
parts(host).canvas.clientWidth = 1800;
|
||||
await viewer.open('a');
|
||||
await tick();
|
||||
assert.equal(signals.length, 1);
|
||||
assert.equal(signals[0].aborted, false);
|
||||
await viewer.open('f');
|
||||
assert.equal(signals[0].aborted, true, 'not needed any more');
|
||||
});
|
||||
|
||||
test('a lost WebGL context while the first panorama loads releases its bitmap', async () => {
|
||||
let closed = false;
|
||||
let built = 0;
|
||||
let land = null;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url) =>
|
||||
new Promise((resolve) => {
|
||||
land = () => resolve({ src: url, close: () => (closed = true) });
|
||||
}),
|
||||
// The first renderer is lost mid-load; a new one cannot be built.
|
||||
createRenderer: () => {
|
||||
built++;
|
||||
if (built > 1) throw new Error('context lost');
|
||||
return {
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
const opening = viewer.open('a');
|
||||
assert.ok(await until(() => land !== null), 'the panorama is loading');
|
||||
parts(host).canvas.fire('webglcontextlost', {});
|
||||
const first = land;
|
||||
first();
|
||||
// The panorama failed: the front view is loaded flat instead.
|
||||
assert.ok(await until(() => land !== first), 'the flat fallback loads');
|
||||
land();
|
||||
await opening;
|
||||
assert.equal(built, 2, 'a new renderer was tried');
|
||||
assert.equal(closed, true, 'the bitmap no renderer took is closed');
|
||||
});
|
||||
|
||||
test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered to the provider', async () => {
|
||||
let asked = 0;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
onEscape: () => ++asked > 0,
|
||||
load: async (url) => ({ src: url }),
|
||||
});
|
||||
const host = fakeHost();
|
||||
const wrap = fakeHost();
|
||||
host.closest = (selector) => (selector === '.sl-viewer-wrap' ? wrap : null);
|
||||
await viewer.mount(host);
|
||||
const flags = { stopped: false, prevented: false };
|
||||
// Focus back on EXPAND after a shrink: the key reaches the wrap, not the photo.
|
||||
wrap.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
assert.equal(asked, 1);
|
||||
assert.deepEqual(flags, { stopped: true, prevented: true });
|
||||
viewer.unmount();
|
||||
wrap.fire('keydown', { key: 'Escape' });
|
||||
assert.equal(asked, 1, 'unmounted: the listener is gone');
|
||||
});
|
||||
|
||||
test('a picture file or bitmap past any real photo’s size is refused', async (t) => {
|
||||
const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } =
|
||||
await import('./photoViewer.js');
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
createImageBitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, saved));
|
||||
let closed = 0;
|
||||
const bitmap = (width, height) => ({ width, height, close: () => closed++ });
|
||||
globalThis.createImageBitmap = async (blob) => blob.bitmap;
|
||||
const respond = ({ length, size, width = 100, height = 50 }) =>
|
||||
(globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: new Map([['content-length', String(length ?? '')]]),
|
||||
blob: async () => ({
|
||||
size: size ?? 10,
|
||||
bitmap: bitmap(width, height),
|
||||
slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }),
|
||||
}),
|
||||
}));
|
||||
const refusedForSize = (error) => {
|
||||
assert.match(error.message, /could not be loaded/);
|
||||
assert.equal(error.tooLarge, true, 'marked as a size refusal');
|
||||
return true;
|
||||
};
|
||||
respond({ length: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
respond({ size: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
respond({ width: MAX_IMAGE_SIDE + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
assert.equal(closed, 1, 'the oversize bitmap is released');
|
||||
// No length given, a body that keeps coming: cut off while it streams.
|
||||
let pulled = 0;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: new Map(),
|
||||
body: new ReadableStream({
|
||||
pull(controller) {
|
||||
pulled++;
|
||||
controller.enqueue(new Uint8Array(8 * 1024 * 1024));
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
assert.ok(pulled * 8 * 1024 * 1024 <= MAX_IMAGE_BYTES + 16 * 1024 * 1024);
|
||||
// Other failures are not size refusals.
|
||||
globalThis.fetch = async () => ({ ok: false, headers: new Map() });
|
||||
await assert.rejects(
|
||||
loadImage('https://x/a.jpg'),
|
||||
(error) => !error.tooLarge,
|
||||
);
|
||||
respond({});
|
||||
assert.equal((await loadImage('https://x/a.jpg')).width, 100);
|
||||
});
|
||||
|
||||
/** A viewer whose loads are held, recording each load's signal by url. */
|
||||
function heldViewer() {
|
||||
const pending = new Map();
|
||||
const signals = new Map();
|
||||
const loads = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
loads.push(url);
|
||||
signals.set(url, options.signal);
|
||||
options.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
pending.set(url, { resolve, reject });
|
||||
}),
|
||||
});
|
||||
const release = (url) => {
|
||||
pending.get(url).resolve({ src: url });
|
||||
pending.delete(url);
|
||||
};
|
||||
return { viewer, pending, signals, loads, release, host: fakeHost() };
|
||||
}
|
||||
|
||||
test('a new open, a close or an unmount stops the downloads of the open before it', async () => {
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
const first = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.signals.has('f-first')));
|
||||
const second = env.viewer.open('g');
|
||||
assert.equal(env.signals.get('f-first').aborted, true, 'f is not wanted now');
|
||||
await first.catch(() => {});
|
||||
assert.ok(await until(() => env.signals.has('g-first')));
|
||||
env.viewer.close();
|
||||
assert.equal(env.signals.get('g-first').aborted, true, 'closed');
|
||||
await second.catch(() => {});
|
||||
const third = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.viewer.unmount();
|
||||
assert.equal(env.signals.get('f-first').aborted, true, 'unmounted');
|
||||
await third.catch(() => {});
|
||||
});
|
||||
|
||||
test('a sharper flat version on its way stops when another photo opens, and is asked again later', async (t) => {
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
parts(env.host).picture.clientWidth = 1200;
|
||||
const opening = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
assert.ok(
|
||||
await until(() => env.pending.has('f-sharp')),
|
||||
'the 1,200 px viewer asks for more',
|
||||
);
|
||||
// Resizes while it loads (the panel animating) do not ask again.
|
||||
env.viewer.resize();
|
||||
env.viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 1);
|
||||
// g opens (and fails): f's sharp download stops, f stays on screen.
|
||||
const failing = env.viewer.open('g');
|
||||
assert.equal(env.signals.get('f-sharp').aborted, true);
|
||||
assert.ok(await until(() => env.pending.has('g-first')));
|
||||
env.pending.get('g-first').reject(new Error('gone'));
|
||||
env.pending.delete('g-first');
|
||||
await failing.catch(() => {});
|
||||
// The discarded response does not block f's sharp version for good.
|
||||
env.signals.delete('f-sharp');
|
||||
env.viewer.resize();
|
||||
assert.ok(await until(() => env.signals.has('f-sharp')), 'asked again');
|
||||
});
|
||||
|
||||
test('a resize while another photo opens does not upgrade the photo still shown', async (t) => {
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
const opening = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
// g is opening; EXPAND resizes the viewer meanwhile.
|
||||
const next = env.viewer.open('g');
|
||||
assert.ok(await until(() => env.pending.has('g-first')));
|
||||
parts(env.host).picture.clientWidth = 1200;
|
||||
env.viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(env.pending.has('f-sharp'), false, 'f is on its way out');
|
||||
env.release('g-first');
|
||||
await next;
|
||||
assert.ok(await until(() => env.pending.has('g-sharp')), 'g gets it');
|
||||
});
|
||||
|
||||
test('a panorama is always decoded at a bounded size', async () => {
|
||||
const env = heldViewer();
|
||||
const options = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url, opts) => {
|
||||
options.push(opts);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
await viewer.mount(env.host);
|
||||
await viewer.open('a');
|
||||
// SOURCES' panoFirst names no size: the viewer still bounds it.
|
||||
assert.deepEqual(options[0].resize, [2048, 1024]);
|
||||
});
|
||||
|
||||
test('a header that declares a huge image is refused before anything decodes it', async (t) => {
|
||||
const { loadImage } = await import('./photoViewer.js');
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
createImageBitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, saved));
|
||||
let decoded = 0;
|
||||
globalThis.createImageBitmap = async () => {
|
||||
decoded++;
|
||||
return { width: 10, height: 10 };
|
||||
};
|
||||
// A 1 kB PNG that says it is 16,384 × 16,384.
|
||||
const png = new Uint8Array(1024);
|
||||
png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
png.set(
|
||||
[...'IHDR'].map((c) => c.charCodeAt(0)),
|
||||
12,
|
||||
);
|
||||
png.set([0, 0, 0x40, 0, 0, 0, 0x40, 0], 16);
|
||||
globalThis.fetch = async () => new Response(png);
|
||||
await assert.rejects(
|
||||
loadImage('https://x/bomb.png'),
|
||||
(error) => error.tooLarge === true,
|
||||
);
|
||||
assert.equal(decoded, 0, 'never decoded');
|
||||
});
|
||||
|
||||
test('an <img> that turns out too large is refused and let go', async (t) => {
|
||||
const { loadImage, MAX_IMAGE_SIDE } = await import('./photoViewer.js');
|
||||
const saved = globalThis.Image;
|
||||
t.after(() => (globalThis.Image = saved));
|
||||
let size = [MAX_IMAGE_SIDE + 1, 100];
|
||||
const images = [];
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
images.push(this);
|
||||
}
|
||||
set src(value) {
|
||||
this._src = value;
|
||||
if (value)
|
||||
queueMicrotask(() => {
|
||||
[this.naturalWidth, this.naturalHeight] = size;
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
loadImage('https://x/a.jpg', { crossOrigin: false }),
|
||||
(error) => error.tooLarge === true,
|
||||
);
|
||||
assert.equal(images.at(-1).src, '', 'its pixels let go');
|
||||
size = [4000, 3000];
|
||||
const ok = await loadImage('https://x/b.jpg', { crossOrigin: false });
|
||||
assert.equal(ok.naturalWidth, 4000);
|
||||
// An aborted <img> load stops and rejects.
|
||||
size = [10, 10];
|
||||
const controller = new AbortController();
|
||||
const aborted = loadImage('https://x/c.jpg', {
|
||||
crossOrigin: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(aborted);
|
||||
assert.equal(images.at(-1).src, '', 'its download stops');
|
||||
});
|
||||
@@ -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,46 @@
|
||||
/** 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',
|
||||
panoramax: '#ff6f00',
|
||||
});
|
||||
|
||||
/** 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;
|
||||
}
|
||||
Loaded 100 of 675 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user